├── test ├── test_helper.exs ├── plug_signature │ ├── parser_test.exs │ ├── callbacks_test.exs │ ├── config_test.exs │ └── signature_string_test.exs └── plug_signature_test.exs ├── plug_signature_example ├── priv │ ├── hmac.key │ ├── ec.pub │ ├── ec.key │ ├── ec.pem │ ├── rsa.pub │ ├── rsa.key │ └── rsa.pem ├── lib │ ├── plug_signature_example │ │ ├── application.ex │ │ ├── authentication.ex │ │ ├── client.ex │ │ └── middleware │ │ │ └── http_signature_auth.ex │ └── plug_signature_example.ex ├── mix.exs ├── README.md ├── request.sh └── mix.lock ├── .formatter.exs ├── .gitignore ├── lib ├── plug_signature │ ├── callback.ex │ ├── signature_string.ex │ ├── parser.ex │ ├── crypto.ex │ ├── config.ex │ └── conn_test.ex └── plug_signature.ex ├── mix.exs ├── .github └── workflows │ └── main.yml ├── LICENSE ├── README.md └── src └── plug_signature_http_date.erl /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /plug_signature_example/priv/hmac.key: -------------------------------------------------------------------------------- 1 | supersecrethmackey -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | import_deps: [:plug] 5 | ] 6 | -------------------------------------------------------------------------------- /plug_signature_example/priv/ec.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEyOsVbBzt8/ueHICrEXxN2+OiDUL8 3 | VTf1VUwJ8NhubqhVwbGT+YstpEGLkUdqo4bIRSOXgMaK0/+a5JiQVsnLZQ== 4 | -----END PUBLIC KEY----- 5 | 6 | -------------------------------------------------------------------------------- /plug_signature_example/priv/ec.key: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PRIVATE KEY----- 2 | MHcCAQEEIJ/DxtD0tOZaEZD78/Brlk7gHzb+DFpD/cQXvyJdWPVsoAoGCCqGSM49 3 | AwEHoUQDQgAEyOsVbBzt8/ueHICrEXxN2+OiDUL8VTf1VUwJ8NhubqhVwbGT+Yst 4 | pEGLkUdqo4bIRSOXgMaK0/+a5JiQVsnLZQ== 5 | -----END EC PRIVATE KEY----- 6 | 7 | -------------------------------------------------------------------------------- /plug_signature_example/priv/ec.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PRIVATE KEY----- 2 | MHcCAQEEIJ/DxtD0tOZaEZD78/Brlk7gHzb+DFpD/cQXvyJdWPVsoAoGCCqGSM49 3 | AwEHoUQDQgAEyOsVbBzt8/ueHICrEXxN2+OiDUL8VTf1VUwJ8NhubqhVwbGT+Yst 4 | pEGLkUdqo4bIRSOXgMaK0/+a5JiQVsnLZQ== 5 | -----END EC PRIVATE KEY----- 6 | 7 | -----BEGIN PUBLIC KEY----- 8 | MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEyOsVbBzt8/ueHICrEXxN2+OiDUL8 9 | VTf1VUwJ8NhubqhVwbGT+YstpEGLkUdqo4bIRSOXgMaK0/+a5JiQVsnLZQ== 10 | -----END PUBLIC KEY----- 11 | 12 | -------------------------------------------------------------------------------- /plug_signature_example/priv/rsa.pub: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuIVptrhDcMlpWm3MmPwd 3 | akIA9mkTNi8PyrklorqMc3SDhbI/rkgOLtRJr588XnT8Yb35fgallF9MpBzTrg2v 4 | V636wmGygzAjjPdqaThdVWlIx4MZUBvdrsuPG6mh0ZCM3pBDiWdGKxmfiKHfSe6q 5 | bciHOSJYu1J1HIN7mDmuyC2g5JVsvEp/Hyy4MnRCZcQf0TjB5bUTHtwrS6l4Z505 6 | 34umxfDUynmRFYJKfDz7Us8FFbxjShHgC8E4fcSdiEN75vZejoRPLxHMLCD8nbvR 7 | cAMOvNXJOLz4Qbx2yzPIr5JLHEG/aRLpkoTD9YW3pXlokV2S5uzMR87b1cGuOGCO 8 | iQIDAQAB 9 | -----END PUBLIC KEY----- 10 | 11 | -------------------------------------------------------------------------------- /plug_signature_example/lib/plug_signature_example/application.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignatureExample.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | def start(_type, _args) do 9 | # List all child processes to be supervised 10 | children = [ 11 | {Plug.Cowboy, scheme: :http, plug: PlugSignatureExample, options: [port: 4040]} 12 | ] 13 | 14 | # See https://hexdocs.pm/elixir/Supervisor.html 15 | # for other strategies and supported options 16 | opts = [strategy: :one_for_one, name: PlugSignatureExample.Supervisor] 17 | Supervisor.start_link(children, opts) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /plug_signature_example/lib/plug_signature_example.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignatureExample do 2 | use Plug.Builder 3 | import Plug.Conn 4 | 5 | plug Plug.Parsers, 6 | parsers: [:urlencoded], 7 | body_reader: {PlugBodyDigest, :digest_body_reader, []} 8 | 9 | plug Plug.Logger 10 | 11 | plug PlugBodyDigest 12 | 13 | plug PlugSignature, 14 | callback_module: PlugSignatureExample.Authentication, 15 | on_success: {PlugSignature, :assign_client, [:client]}, 16 | headers: "(request-target) (created) host digest" 17 | 18 | plug :echo 19 | 20 | def echo(conn, _opts) do 21 | client = conn.assigns[:client] 22 | params = conn.params 23 | send_resp(conn, 200, "Client: #{client}\nParams: #{inspect(params)}\n\n") 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /plug_signature_example/lib/plug_signature_example/authentication.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignatureExample.Authentication do 2 | @moduledoc """ 3 | Wrapper around PlugSignature 4 | """ 5 | 6 | @behaviour PlugSignature.Callback 7 | 8 | @impl true 9 | def client_lookup("hmac.key", _algorithm, _conn) do 10 | {:ok, "Some client", File.read!("priv/hmac.key")} 11 | end 12 | 13 | def client_lookup(key_id, _algorithm, _conn) do 14 | path = Application.app_dir(:plug_signature_example, "priv") 15 | 16 | with {:ok, pem} <- File.read(Path.join(path, key_id)), 17 | {:ok, public_key} <- X509.PublicKey.from_pem(pem) do 18 | {:ok, "Some client", public_key} 19 | else 20 | _error -> 21 | {:error, "Key not found"} 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | plug_signature-*.tar 24 | 25 | # Because we're a library 26 | mix.lock 27 | 28 | plug_signature_example/_build/ 29 | plug_signature_example/deps/ 30 | -------------------------------------------------------------------------------- /plug_signature_example/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugSignatureExample.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :plug_signature_example, 7 | version: "0.2.0", 8 | elixir: "~> 1.10", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | # Run "mix help compile.app" to learn about applications. 15 | def application do 16 | [ 17 | extra_applications: [:logger], 18 | mod: {PlugSignatureExample.Application, []} 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:plug_signature, path: ".."}, 26 | {:plug_body_digest, "~> 0.5"}, 27 | {:plug_cowboy, "~> 2.1"}, 28 | {:x509, "~> 0.5"}, 29 | {:tesla, "~> 1.3"} 30 | ] 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /plug_signature_example/lib/plug_signature_example/client.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignatureExample.Client do 2 | def new(key_id, key_path) do 3 | private_key = 4 | key_path 5 | |> File.read!() 6 | |> X509.PrivateKey.from_pem!() 7 | 8 | Tesla.client([ 9 | {Tesla.Middleware.BaseUrl, "http://localhost:4040"}, 10 | # Ensure body is encoded before invoking HttpSignatureAuth, so a 11 | # digest can be calculated 12 | Tesla.Middleware.FormUrlencoded, 13 | {PlugSignatureExample.Middleware.HttpSignatureAuth, 14 | key_id: key_id, private_key: private_key, headers: "(request-target) (created) host digest"}, 15 | Tesla.Middleware.Logger 16 | ]) 17 | end 18 | 19 | def get(client, params) do 20 | Tesla.get(client, "/", query: params) 21 | end 22 | 23 | def post(client, params) do 24 | Tesla.post(client, "/", params) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/plug_signature/callback.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.Callback do 2 | @moduledoc """ 3 | Behaviour for the callback module that implements client and credential 4 | lookup for `PlugSignature`. 5 | 6 | ## Example 7 | 8 | defmodule MyApp.SignatureAuth do 9 | @behaviour PlugSignature.Callback 10 | 11 | @impl true 12 | def client_lookup(key_id, "hs2019", _conn) do 13 | # ... 14 | {:ok, client, client.hmac_secret} 15 | end 16 | end 17 | """ 18 | 19 | @doc """ 20 | Takes the keyId from the parsed Authorization header, the algorithm name and 21 | the `Plug.Conn` struct, and returns a success or error tuple. 22 | 23 | In case of success, an application-specific term is returned that identifies 24 | the client, along with that client's credentials (a public key or HMAC 25 | secret). 26 | 27 | The `Plug.Conn` struct may be used to select the relevant client, but it 28 | cannot be modified. For instance, the hostname of the request may be needed 29 | to select the correct client in a multi-tennant application. 30 | """ 31 | @callback client_lookup(key_id :: binary(), algorithm :: binary(), conn :: Plug.Conn.t()) :: 32 | {:ok, any(), :public_key.public_key() | binary()} | {:error, String.t()} 33 | end 34 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.11.0" 5 | 6 | def project do 7 | [ 8 | app: :plug_signature, 9 | version: @version, 10 | elixir: "~> 1.10", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | description: description(), 14 | package: package(), 15 | docs: docs(), 16 | source_url: "https://github.com/voltone/plug_signature" 17 | ] 18 | end 19 | 20 | # Run "mix help compile.app" to learn about applications. 21 | def application do 22 | [ 23 | extra_applications: [:logger, :crypto, :public_key] 24 | ] 25 | end 26 | 27 | # Run "mix help deps" to learn about dependencies. 28 | defp deps do 29 | [ 30 | {:plug, "~> 1.5"}, 31 | {:nimble_parsec, "~> 1.0"}, 32 | {:ex_doc, "~> 0.21", only: :dev}, 33 | {:credo, "~> 1.1", only: :dev}, 34 | {:x509, "~> 0.5", only: :test}, 35 | {:cowlib, "~> 2.8", only: :test} 36 | ] 37 | end 38 | 39 | defp description do 40 | "Server side implementation of IETF HTTP signature draft as a reusable Plug" 41 | end 42 | 43 | defp package do 44 | [ 45 | maintainers: ["Bram Verburg"], 46 | licenses: ["BSD-3-Clause"], 47 | links: %{"GitHub" => "https://github.com/voltone/plug_signature"} 48 | ] 49 | end 50 | 51 | defp docs do 52 | [ 53 | main: "readme", 54 | extras: ["README.md"], 55 | source_ref: "v#{@version}" 56 | ] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-20.04 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - pair: 17 | elixir: '1.10.4' 18 | otp: '22.3.4.26' 19 | - pair: 20 | elixir: '1.10.4' 21 | otp: '23.0.4' 22 | - pair: 23 | elixir: '1.11.4' 24 | otp: '23.3.4.17' 25 | - pair: 26 | elixir: '1.12.3' 27 | otp: '24.0.6' 28 | - pair: 29 | elixir: '1.13.4' 30 | otp: '24.3.4.4' 31 | - pair: 32 | elixir: '1.14.0' 33 | otp: '25.0.4' 34 | lint: lint 35 | steps: 36 | - uses: actions/checkout@v2 37 | 38 | - uses: erlef/setup-beam@v1 39 | with: 40 | otp-version: ${{matrix.pair.otp}} 41 | elixir-version: ${{matrix.pair.elixir}} 42 | 43 | - name: Install Dependencies 44 | run: mix deps.get --only test 45 | 46 | - run: mix format --check-formatted 47 | if: ${{ matrix.lint }} 48 | 49 | - run: mix deps.get && mix deps.unlock --check-unused 50 | if: ${{ matrix.lint }} 51 | 52 | - run: mix deps.compile 53 | 54 | - run: mix compile --warnings-as-errors 55 | if: ${{ matrix.lint }} 56 | 57 | - run: mix credo 58 | if: ${{ matrix.lint }} 59 | 60 | - run: mix test 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Bram Verburg 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /test/plug_signature/parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.ParserTest do 2 | use ExUnit.Case 3 | doctest PlugSignature.Parser 4 | 5 | import PlugSignature.Parser 6 | 7 | test "valid" do 8 | assert {:ok, params} = 9 | signature(~s(keyId=123,signature="0123456789abcdef",created=1562570728)) 10 | 11 | assert params[:key_id] == "123" 12 | assert params[:signature] == "0123456789abcdef" 13 | assert params[:created] == "1562570728" 14 | end 15 | 16 | test "extra whitespace" do 17 | assert {:ok, params} = 18 | signature(~s(keyId=123, signature= "0123456789abcdef", created = 1562570728)) 19 | 20 | assert params[:key_id] == "123" 21 | assert params[:signature] == "0123456789abcdef" 22 | assert params[:created] == "1562570728" 23 | end 24 | 25 | test "duplicate params" do 26 | assert {:error, "malformed signature"} = 27 | signature(~s(keyId=123,signature="0123456789abcdef",created=1562570728,keyId=234)) 28 | end 29 | 30 | test "unknown and case mismatch params" do 31 | assert {:ok, params} = 32 | signature(~s(keyColor=red,Signature="0123456789abcdef",created=1562570728)) 33 | 34 | refute :keyColor in Keyword.keys(params) 35 | refute :signature in Keyword.keys(params) 36 | refute :signature in Keyword.keys(params) 37 | assert params[:created] == "1562570728" 38 | end 39 | 40 | test "weird values with quoted characters" do 41 | assert {:ok, params} = signature(~S(keyId="&^%$#@!,{}=\\\"")) 42 | 43 | assert params[:key_id] == ~S(&^%$#@!,{}=\") 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/plug_signature/signature_string.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.SignatureString do 2 | @moduledoc false 3 | 4 | def build(conn, signature_opts, algorithm, header_list) do 5 | signature_string = 6 | header_list 7 | |> Enum.map(&String.downcase/1) 8 | |> Enum.map_join("\n", &header_part(conn, signature_opts, algorithm, &1)) 9 | 10 | {:ok, signature_string} 11 | rescue 12 | # Handle the case where (created) or (expires) pseudo header is not 13 | # available or not supported by the algorithm 14 | _key_error -> {:error, "could not build signature_string"} 15 | end 16 | 17 | defp header_part(conn, _signature_opts, _algorithm, "(request-target)") do 18 | query_part = 19 | case conn.query_string do 20 | "" -> "" 21 | query -> "?#{query}" 22 | end 23 | 24 | "(request-target): #{String.downcase(conn.method)} #{conn.request_path}#{query_part}" 25 | end 26 | 27 | defp header_part(_conn, signature_opts, "hs2019", "(created)") do 28 | "(created): #{Keyword.fetch!(signature_opts, :created)}" 29 | end 30 | 31 | defp header_part(_conn, signature_opts, "hs2019", "(expires)") do 32 | "(expires): #{Keyword.fetch!(signature_opts, :expires)}" 33 | end 34 | 35 | defp header_part(conn, _signature_opts, _algorithm, "host") do 36 | "host: #{conn.host}" 37 | end 38 | 39 | defp header_part(conn, _signature_opts, _algorithm, header_name) do 40 | values = 41 | conn.req_headers 42 | |> Enum.filter(&match?({^header_name, _}, &1)) 43 | |> Enum.map(&String.trim(elem(&1, 1))) 44 | 45 | if values == [], do: raise("Missing header") 46 | 47 | "#{header_name}: #{Enum.join(values, ", ")}" 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /plug_signature_example/priv/rsa.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEAuIVptrhDcMlpWm3MmPwdakIA9mkTNi8PyrklorqMc3SDhbI/ 3 | rkgOLtRJr588XnT8Yb35fgallF9MpBzTrg2vV636wmGygzAjjPdqaThdVWlIx4MZ 4 | UBvdrsuPG6mh0ZCM3pBDiWdGKxmfiKHfSe6qbciHOSJYu1J1HIN7mDmuyC2g5JVs 5 | vEp/Hyy4MnRCZcQf0TjB5bUTHtwrS6l4Z50534umxfDUynmRFYJKfDz7Us8FFbxj 6 | ShHgC8E4fcSdiEN75vZejoRPLxHMLCD8nbvRcAMOvNXJOLz4Qbx2yzPIr5JLHEG/ 7 | aRLpkoTD9YW3pXlokV2S5uzMR87b1cGuOGCOiQIDAQABAoIBAQCfZTglzFUNyB9H 8 | K5RTD27FjJDSS4B6DPtiTr/xK58KWTsIMiuKfNorn9yrZi27Fumx8W7lbA569jv5 9 | hKFjOJUgc70rT0PqyZncOxpkHHmbv6BMILasGfZM+bD833NW2bymwg5lUp4tuyux 10 | 1stRTWdSAKi3NTFbV+aso/QPUrzmU/MMTyneJi7DhtEGH+JywAcAl2u+1ycX1i/E 11 | XrYHH9B1r0zeO/A4ItPTRAoaZOXrJAE7+xjzB5YB+6rzCrmxbtBdZXyPFiYoqZui 12 | dkqqUpq3pxHKS8+0mflNfZTW8WEFgZBxlsXBMYYUDw5iYLgm2F9xYd7iulcLTUow 13 | dOtNdw9RAoGBAO505g3Acn/dYYzUHV7zzVJULEEIpN6lbxcSEz9LfOZM7LN6kDDY 14 | MpMw57mbUG7YYoYYvmLASDvz7G5Okifb8C0Abz4HY5V9O4NnYCS0h8rcEl6mGVET 15 | u1AMoDVttsG2FBJp95HnlVLdpz95eJjrbynLZq/Sa6x+CRlaHNkI20KtAoGBAMYY 16 | sPLJQI21SR3A1PwBY3PEHRMg8UQoCy0u+L/meWtdaG1Nf8kRC1faEXtiSGkLUNoP 17 | WrA7ku4UDWZukaNwWnHZe5Uw564cP4a7dN/ZQx/N+Jg+NuaKhCWVvEjxCuA1ZsVe 18 | U1noJELiqIS8YA8RuhaTD0N3gtIoRTCiQZKuDBLNAoGBAJCmEuOmqQ5NcZ5nEYYG 19 | 6LcXXlz47GIvAouBKHHNze86HJ/nKk6m508IbJjX0VvcIS/tFJh8wZS0q+hh+yD4 20 | tuHlkJWVD+CfvhlA/T5m0LTK+M23fkYDbS3q6sheTG2HkPd2lnpIe/lvgcPsYK6K 21 | qr00qI7hWvWg4s4hLrytNaxlAoGBALeDGClSFuMwFdPiV2w9PQx5mRWnZtpk3jW1 22 | VeswbzrvBVZ8fOyfRYrVEWzj14C4YuYfYzvvdGXpXaCOvYxTAPaHKt1CuN2qfY8r 23 | CVJ1yqEkBi/DMsjPeSv4Uryf0Bt0XQhqIX0geLcdkk+k0rgjC+jtwy4VALP/allr 24 | dqOTaMvhAoGASJOYRLWR0D6aTRNT+An2KIhroP2a3B77Pfm6Z8C3h0hHUyj/9HXL 25 | jHK8BzN8Z46E/OC331wQ370Zg2xMG+KhXCP4X9NL41aGJZFUZIhq17dv5epf1e0s 26 | owASpkiBqL811yHEC98ZHDO1fJdut++4P3MZRWNO4rlQz880j69VnDk= 27 | -----END RSA PRIVATE KEY----- 28 | 29 | -------------------------------------------------------------------------------- /plug_signature_example/priv/rsa.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEAuIVptrhDcMlpWm3MmPwdakIA9mkTNi8PyrklorqMc3SDhbI/ 3 | rkgOLtRJr588XnT8Yb35fgallF9MpBzTrg2vV636wmGygzAjjPdqaThdVWlIx4MZ 4 | UBvdrsuPG6mh0ZCM3pBDiWdGKxmfiKHfSe6qbciHOSJYu1J1HIN7mDmuyC2g5JVs 5 | vEp/Hyy4MnRCZcQf0TjB5bUTHtwrS6l4Z50534umxfDUynmRFYJKfDz7Us8FFbxj 6 | ShHgC8E4fcSdiEN75vZejoRPLxHMLCD8nbvRcAMOvNXJOLz4Qbx2yzPIr5JLHEG/ 7 | aRLpkoTD9YW3pXlokV2S5uzMR87b1cGuOGCOiQIDAQABAoIBAQCfZTglzFUNyB9H 8 | K5RTD27FjJDSS4B6DPtiTr/xK58KWTsIMiuKfNorn9yrZi27Fumx8W7lbA569jv5 9 | hKFjOJUgc70rT0PqyZncOxpkHHmbv6BMILasGfZM+bD833NW2bymwg5lUp4tuyux 10 | 1stRTWdSAKi3NTFbV+aso/QPUrzmU/MMTyneJi7DhtEGH+JywAcAl2u+1ycX1i/E 11 | XrYHH9B1r0zeO/A4ItPTRAoaZOXrJAE7+xjzB5YB+6rzCrmxbtBdZXyPFiYoqZui 12 | dkqqUpq3pxHKS8+0mflNfZTW8WEFgZBxlsXBMYYUDw5iYLgm2F9xYd7iulcLTUow 13 | dOtNdw9RAoGBAO505g3Acn/dYYzUHV7zzVJULEEIpN6lbxcSEz9LfOZM7LN6kDDY 14 | MpMw57mbUG7YYoYYvmLASDvz7G5Okifb8C0Abz4HY5V9O4NnYCS0h8rcEl6mGVET 15 | u1AMoDVttsG2FBJp95HnlVLdpz95eJjrbynLZq/Sa6x+CRlaHNkI20KtAoGBAMYY 16 | sPLJQI21SR3A1PwBY3PEHRMg8UQoCy0u+L/meWtdaG1Nf8kRC1faEXtiSGkLUNoP 17 | WrA7ku4UDWZukaNwWnHZe5Uw564cP4a7dN/ZQx/N+Jg+NuaKhCWVvEjxCuA1ZsVe 18 | U1noJELiqIS8YA8RuhaTD0N3gtIoRTCiQZKuDBLNAoGBAJCmEuOmqQ5NcZ5nEYYG 19 | 6LcXXlz47GIvAouBKHHNze86HJ/nKk6m508IbJjX0VvcIS/tFJh8wZS0q+hh+yD4 20 | tuHlkJWVD+CfvhlA/T5m0LTK+M23fkYDbS3q6sheTG2HkPd2lnpIe/lvgcPsYK6K 21 | qr00qI7hWvWg4s4hLrytNaxlAoGBALeDGClSFuMwFdPiV2w9PQx5mRWnZtpk3jW1 22 | VeswbzrvBVZ8fOyfRYrVEWzj14C4YuYfYzvvdGXpXaCOvYxTAPaHKt1CuN2qfY8r 23 | CVJ1yqEkBi/DMsjPeSv4Uryf0Bt0XQhqIX0geLcdkk+k0rgjC+jtwy4VALP/allr 24 | dqOTaMvhAoGASJOYRLWR0D6aTRNT+An2KIhroP2a3B77Pfm6Z8C3h0hHUyj/9HXL 25 | jHK8BzN8Z46E/OC331wQ370Zg2xMG+KhXCP4X9NL41aGJZFUZIhq17dv5epf1e0s 26 | owASpkiBqL811yHEC98ZHDO1fJdut++4P3MZRWNO4rlQz880j69VnDk= 27 | -----END RSA PRIVATE KEY----- 28 | 29 | -----BEGIN PUBLIC KEY----- 30 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuIVptrhDcMlpWm3MmPwd 31 | akIA9mkTNi8PyrklorqMc3SDhbI/rkgOLtRJr588XnT8Yb35fgallF9MpBzTrg2v 32 | V636wmGygzAjjPdqaThdVWlIx4MZUBvdrsuPG6mh0ZCM3pBDiWdGKxmfiKHfSe6q 33 | bciHOSJYu1J1HIN7mDmuyC2g5JVsvEp/Hyy4MnRCZcQf0TjB5bUTHtwrS6l4Z505 34 | 34umxfDUynmRFYJKfDz7Us8FFbxjShHgC8E4fcSdiEN75vZejoRPLxHMLCD8nbvR 35 | cAMOvNXJOLz4Qbx2yzPIr5JLHEG/aRLpkoTD9YW3pXlokV2S5uzMR87b1cGuOGCO 36 | iQIDAQAB 37 | -----END PUBLIC KEY----- 38 | 39 | -------------------------------------------------------------------------------- /plug_signature_example/README.md: -------------------------------------------------------------------------------- 1 | # PlugSignatureExample 2 | 3 | Application demonstrating the use of `PlugSignature` and `PlugBodyDigest` for 4 | HTTP request authentication. 5 | 6 | ## Starting the application 7 | 8 | Before starting the application for the first time, ensure the necessary 9 | dependencies have been installed using `mix deps.get`. 10 | 11 | Run the application using `mix run --no-halt` or `iex -S mix`. The server 12 | listens on port 4040. 13 | 14 | ## OpenSSL/cURL client 15 | 16 | A bash script called `request.sh` may be used to trigger a number of 17 | scenarios that demonstrate the functionality provided by `PlugSignature` and 18 | `PlugBodyDigest`. The script is invoked as `request.sh [ []]`. 19 | 20 | Scenarios: 21 | 22 | * `valid` (default) - make a valid request, signed using ECDSA 23 | * `expired` - make a request with a 10 minute old signature, resulting in a 24 | 401 response 25 | * `digest` - make a request with an checksum in the Digest header that does 26 | not match the request body, resulting in a 403 response 27 | * `key` - make a request with an unknown keyId, resulting in a 401 response 28 | * `headers` - make a request with a signature that does not cover the 29 | minimum set of required headers, resulting in a 401 response 30 | * `signature` - make a request with an invalid sigature value, resulting in 31 | a 401 response 32 | * `rsa` - make a valid request, signed using RSASSA-PSS 33 | * `hmac` - make a valid request, using HMAC authentication 34 | 35 | The optional second argument selects the request method: `post` (default) or 36 | `get`. 37 | 38 | The output shows the HTTP request headers, including the Digest and 39 | Authorization headers, as well as response headers and body. 40 | 41 | ## Tesla client 42 | 43 | To use the Tesla client, start an IEx session with `iex -S mix`, the create a 44 | client and send requests to the server as follows: 45 | 46 | ```elixir 47 | iex> client = PlugSignatureExample.Client.new("ec.pub", "priv/ec.key") 48 | %Tesla.Client{ 49 | # ... 50 | } 51 | iex> PlugSignatureExample.Client.get(client, %{test: 123}) 52 | {:ok, 53 | %Tesla.Env{ 54 | # ... 55 | status: 200, 56 | # ... 57 | } 58 | } 59 | iex> PlugSignatureExample.Client.post(client, %{test: 123}) 60 | {:ok, 61 | %Tesla.Env{ 62 | # ... 63 | status: 200, 64 | # ... 65 | } 66 | } 67 | ``` 68 | -------------------------------------------------------------------------------- /plug_signature_example/lib/plug_signature_example/middleware/http_signature_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignatureExample.Middleware.HttpSignatureAuth do 2 | @moduledoc """ 3 | Tesla middleware for HTTP request signing according to 4 | https://tools.ietf.org/html/draft-cavage-http-signatures-11 5 | 6 | Supports the hs2019 algorithm only. 7 | """ 8 | 9 | alias PlugSignature.Crypto 10 | 11 | @behaviour Tesla.Middleware 12 | 13 | def call(env, next, opts) do 14 | key_id = opts[:key_id] 15 | private_key = opts[:private_key] 16 | headers = Keyword.get(opts, :headers, "(created)") 17 | 18 | env 19 | |> with_digest() 20 | |> signed(key_id, private_key, headers) 21 | |> Tesla.run(next) 22 | end 23 | 24 | defp with_digest(%{body: body} = env) do 25 | Tesla.put_header(env, "digest", "SHA-256=#{digest(body)}") 26 | end 27 | 28 | defp digest(nil), do: :crypto.hash(:sha256, "") |> Base.encode64() 29 | defp digest(body), do: :crypto.hash(:sha256, body) |> Base.encode64() 30 | 31 | defp signed(env, key_id, private_key, headers) do 32 | created = 33 | DateTime.utc_now() 34 | |> DateTime.to_unix() 35 | 36 | to_sign = build_to_sign(headers, env, created) 37 | 38 | signature = 39 | to_sign 40 | |> Crypto.sign!("hs2019", private_key) 41 | |> Base.encode64() 42 | 43 | authorization = 44 | ~s[Signature keyId="#{key_id}",signature="#{signature}",algorithm="hs2019",created=#{ 45 | created 46 | },headers="#{headers}"] 47 | 48 | Tesla.put_header(env, "authorization", authorization) 49 | end 50 | 51 | defp build_to_sign(headers, env, created) do 52 | url = URI.parse(env.url) 53 | path = url.path 54 | host = url.authority 55 | 56 | query = 57 | case {url.query, URI.encode_query(env.query)} do 58 | {nil, ""} -> "" 59 | {from_url, ""} when byte_size(from_url) > 0 -> "?" <> from_url 60 | {nil, from_env} when byte_size(from_env) > 0 -> "?" <> from_env 61 | end 62 | 63 | request_target = "#{env.method |> to_string() |> String.downcase()} #{path}#{query}" 64 | 65 | headers 66 | |> String.split() 67 | |> Enum.map(fn 68 | "(request-target)" -> 69 | "(request-target): #{request_target}" 70 | 71 | "(created)" -> 72 | "(created): #{created}" 73 | 74 | "host" -> 75 | "host: #{host}" 76 | 77 | header -> 78 | "#{header}: #{Tesla.get_header(env, header)}" 79 | end) 80 | |> Enum.join("\n") 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /plug_signature_example/request.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # base64 output differs between Linux and OS X 4 | case $(uname) in 5 | Darwin) 6 | base64_args="" 7 | ;; 8 | Linux) 9 | base64_args="-w0" 10 | ;; 11 | esac 12 | 13 | usage () { 14 | cat << EOF 15 | Usage: $0 [ []] 16 | 17 | Scenarios: valid | expired | digest | key | headers | signature | rsa | hmac 18 | Methods: post | get 19 | 20 | EOF 21 | exit 1 22 | } 23 | 24 | scenario=${1:-valid} 25 | method=${2:-post} 26 | 27 | host=localhost:4040 28 | params="query=test" 29 | headers="(request-target) (created) host digest" 30 | private_key=priv/ec.key 31 | key_id=ec.pub 32 | 33 | case ${method,,} in 34 | post) 35 | request="/" 36 | body="${params}" 37 | ;; 38 | get) 39 | request="/?${params}" 40 | body="" 41 | ;; 42 | *) 43 | usage 44 | ;; 45 | esac 46 | 47 | body_sha256=$(echo -ne "${body}" | openssl dgst -sha256 -binary | base64 ${base64_args}) 48 | digest="sha-256=${body_sha256}" 49 | 50 | created=$(date +%s) 51 | 52 | case ${scenario,,} in 53 | valid) 54 | ;; 55 | expired) 56 | created=$(date -v -10M +%s) 57 | ;; 58 | digest) 59 | digest="sha-256=v7s8tN6onk17uf3xx9VzMFXenFUkD5G7Yog1laUcjuA=" 60 | ;; 61 | key) 62 | key_id=unknown.pub 63 | ;; 64 | headers) 65 | headers="(request-target) (created) host" 66 | override_signature=$(echo -ne "(request-target): ${method,,} ${request}\n(created): ${created}\nhost: ${host}" | \ 67 | openssl dgst -sha512 -sign ${private_key} | \ 68 | base64 ${base64_args}) 69 | ;; 70 | signature) 71 | override_signature=$(echo -ne "(request-target): ${method,,} /invalid\n(created): ${created}\nhost: ${host}\ndigest: ${digest}" | \ 72 | openssl dgst -sha512 -sign ${private_key} | \ 73 | base64 ${base64_args}) 74 | ;; 75 | rsa) 76 | private_key=priv/rsa.key 77 | override_signature=$(echo -ne "(request-target): ${method,,} ${request}\n(created): ${created}\nhost: ${host}\ndigest: ${digest}" | \ 78 | openssl dgst -sha512 -sigopt rsa_padding_mode:pss -sign ${private_key} | \ 79 | base64 ${base64_args}) 80 | key_id=rsa.pub 81 | ;; 82 | hmac) 83 | hmac_secret=$(cat priv/hmac.key) 84 | override_signature=$(echo -ne "(request-target): ${method,,} ${request}\n(created): ${created}\nhost: ${host}\ndigest: ${digest}" | \ 85 | openssl dgst -hmac ${hmac_secret} -sha512 -binary | \ 86 | base64 ${base64_args}) 87 | key_id=hmac.key 88 | ;; 89 | *) 90 | usage 91 | ;; 92 | esac 93 | 94 | signature=$(echo -ne "(request-target): ${method,,} ${request}\n(created): ${created}\nhost: ${host}\ndigest: ${digest}" | \ 95 | openssl dgst -sha512 -sign ${private_key} | \ 96 | base64 ${base64_args}) 97 | 98 | case ${method,,} in 99 | post) 100 | curl -v \ 101 | -d ${body} \ 102 | -H "Digest: $digest" \ 103 | -H "Authorization: Signature keyId=${key_id},signature=\"${override_signature:-$signature}\",headers=\"${headers}\",created=${created}" \ 104 | "http://${host}${request}" 105 | ;; 106 | get) 107 | curl -v \ 108 | -H "Digest: $digest" \ 109 | -H "Authorization: Signature keyId=${key_id},signature=\"${override_signature:-$signature}\",headers=\"${headers}\",created=${created}" \ 110 | "http://${host}${request}" 111 | ;; 112 | esac 113 | -------------------------------------------------------------------------------- /plug_signature_example/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, 3 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 4 | "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, 5 | "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, 6 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 7 | "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, 8 | "plug_body_digest": {:hex, :plug_body_digest, "0.7.0", "5834c94d6e54e687e98e31bdf7eb0b25b60a0eadb99a13b5e78d67bfd0acc6c6", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4a429c1ef54bb0f427fa9b457c3ba928070350b559ea880ab2cb463675c97af9"}, 9 | "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, 10 | "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, 11 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 12 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, 13 | "tesla": {:hex, :tesla, "1.4.4", "bb89aa0c9745190930366f6a2ac612cdf2d0e4d7fff449861baa7875afd797b2", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d5503a49f9dec1b287567ea8712d085947e247cb11b06bc54adb05bfde466457"}, 14 | "x509": {:hex, :x509, "0.8.5", "22b2c5dfc87b05d46595d3764f41a23fcb7360f891e0464f1a2ec118177cd4e4", [:mix], [], "hexpm", "c63eb89e8bbe8a5e21b6404ad1082faff670e38b74960297f90d023177949e07"}, 15 | } 16 | -------------------------------------------------------------------------------- /lib/plug_signature/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.Parser do 2 | @moduledoc false 3 | 4 | # credo:disable-for-this-file 5 | 6 | # parsec:PlugSignature.Parser 7 | 8 | import NimbleParsec 9 | 10 | defmodule Helpers do 11 | @moduledoc false 12 | 13 | import NimbleParsec 14 | 15 | # rfc7230 16 | 17 | ### section 3.2.3 18 | # RWS = 1*( SP / HTAB ) 19 | # ; required whitespace 20 | # OWS = *( SP / HTAB ) 21 | # ; optional whitespace 22 | # BWS = OWS 23 | # ; "bad" whitespace 24 | def rws(combinator \\ empty()), do: times(combinator, ascii_char([?\s, ?\t]), min: 1) 25 | def ows(combinator \\ empty()), do: repeat(combinator, ascii_char([?\s, ?\t])) 26 | def bws(combinator \\ empty()), do: ows(combinator) 27 | 28 | ### section 3.2.6 29 | # tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" 30 | # / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" 31 | # / DIGIT / ALPHA 32 | # ; any VCHAR, except delimiters 33 | # token = 1*tchar 34 | # obs-text = %x80-FF 35 | # qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text 36 | # quoted-pair = quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text ) 37 | # quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE 38 | def tchar(combinator \\ empty()) do 39 | ascii_char( 40 | combinator, 41 | [?!, ?#, ?$, ?%, ?&, ?', ?*, ?+, ?-, ?., ?^, ?_, ?`, ?|, ?~] ++ 42 | [?0..?9, ?a..?z, ?A..?Z] 43 | ) 44 | end 45 | 46 | def token(combinator \\ empty()), do: times(combinator, tchar(), min: 1) 47 | 48 | def qdtext(combinator \\ empty()) do 49 | ascii_char(combinator, [?\t, ?\s, 0x21, 0x23..0x5B, 0x5D..0x7E, 0x80..0xFF]) 50 | end 51 | 52 | def quoted_pair(combinator \\ empty()) do 53 | combinator 54 | |> ascii_char([?\\]) 55 | |> ascii_char([?\t, ?\s, 0x21..0x7E, 0x80..0xFF]) 56 | end 57 | 58 | def quoted_string(combinator \\ empty()) do 59 | combinator 60 | |> ascii_char([?"]) 61 | |> repeat(choice([qdtext(), quoted_pair()])) 62 | |> ascii_char([?"]) 63 | end 64 | 65 | ### Based on RFC7235 Appendix C 66 | # auth-param = token BWS "=" BWS ( token / quoted-string ) 67 | def equals(combinator \\ empty()) do 68 | combinator 69 | |> bws() 70 | |> ascii_char([?=]) 71 | |> bws() 72 | end 73 | 74 | def comma(combinator \\ empty()) do 75 | combinator 76 | |> bws() 77 | |> ascii_char([?,]) 78 | |> bws() 79 | end 80 | 81 | def generic_param(combinator \\ empty()) do 82 | combinator 83 | |> token() 84 | |> equals() 85 | |> choice([token(), quoted_string()]) 86 | end 87 | 88 | def named_param(combinator \\ empty(), name, tag) do 89 | combinator 90 | |> ignore( 91 | string(name) 92 | |> equals() 93 | ) 94 | |> tag(choice([token(), quoted_string()]), tag) 95 | end 96 | 97 | def signature_param(combinator \\ empty()) do 98 | choice(combinator, [ 99 | named_param("keyId", :key_id), 100 | named_param("signature", :signature), 101 | named_param("algorithm", :algorithm), 102 | named_param("created", :created), 103 | named_param("expires", :expires), 104 | named_param("headers", :headers), 105 | ignore(generic_param()) 106 | ]) 107 | end 108 | 109 | def signature_params(combinator \\ empty()) do 110 | combinator 111 | |> optional(signature_param()) 112 | |> repeat(ignore(comma()) |> optional(signature_param())) 113 | end 114 | end 115 | 116 | defparsecp(:signature_params, Helpers.signature_params()) 117 | 118 | # parsec:PlugSignature.Parser 119 | 120 | def signature(input) do 121 | with {:ok, result, "", _, _, _} <- signature_params(input), 122 | false <- duplicate_keys?(result) do 123 | {:ok, Enum.map(result, &unescape/1)} 124 | else 125 | _ -> 126 | {:error, "malformed signature"} 127 | end 128 | end 129 | 130 | defp duplicate_keys?(keyword_list) do 131 | keys = Keyword.keys(keyword_list) 132 | unique_keys = Enum.uniq(keys) 133 | length(keys) != length(unique_keys) 134 | end 135 | 136 | defp unescape({key, [?" | rest]}) do 137 | {key, to_string(unescape_value(rest))} 138 | end 139 | 140 | defp unescape({key, value}) do 141 | {key, to_string(value)} 142 | end 143 | 144 | defp unescape_value(value, acc \\ []) 145 | 146 | defp unescape_value([?"], acc), do: Enum.reverse(acc) 147 | 148 | defp unescape_value([?\\, c | rest], acc) do 149 | unescape_value(rest, [c | acc]) 150 | end 151 | 152 | defp unescape_value([c | rest], acc) do 153 | unescape_value(rest, [c | acc]) 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /lib/plug_signature/crypto.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.Crypto do 2 | @moduledoc """ 3 | This module exposes the cryptographic core functions used in HTTP signatures. 4 | These functions may be used to implement clients, or alternative server-side 5 | implementations, e.g. for Raxx. 6 | 7 | Supported algorithms: 8 | 9 | * 'hs2019', using ECDSA, RSASSA-PSS or HMAC (all with SHA-512) 10 | * 'rsa-sha256', using RSASSA-PKCS1-v1_5 11 | * 'rsa-sha1', using RSASSA-PKCS1-v1_5 12 | * 'ecdsa-sha256' 13 | * 'hmac-sha256' 14 | """ 15 | 16 | require Record 17 | 18 | Record.defrecordp( 19 | :rsa_public_key, 20 | :RSAPublicKey, 21 | Record.extract(:RSAPublicKey, from_lib: "public_key/include/OTP-PUB-KEY.hrl") 22 | ) 23 | 24 | Record.defrecordp( 25 | :rsa_private_key, 26 | :RSAPrivateKey, 27 | Record.extract(:RSAPrivateKey, from_lib: "public_key/include/OTP-PUB-KEY.hrl") 28 | ) 29 | 30 | Record.defrecordp( 31 | :ec_private_key, 32 | :ECPrivateKey, 33 | Record.extract(:ECPrivateKey, from_lib: "public_key/include/OTP-PUB-KEY.hrl") 34 | ) 35 | 36 | Record.defrecordp( 37 | :ecdsa_signature, 38 | :"ECDSA-Sig-Value", 39 | Record.extract(:"ECDSA-Sig-Value", from_lib: "public_key/include/OTP-PUB-KEY.hrl") 40 | ) 41 | 42 | @doc """ 43 | Verifies a signature value. Raises in case of errors. 44 | """ 45 | def verify!(payload, "hs2019", signature, rsa_public_key(publicExponent: e, modulus: n)) do 46 | # Use PSS padding; requires workaround for https://bugs.erlang.org/browse/ERL-878 47 | :crypto.verify(:rsa, :sha512, payload, signature, [e, n], rsa_padding: :rsa_pkcs1_pss_padding) 48 | end 49 | 50 | def verify!(payload, "hs2019", signature, {_point, _ecpk_parameters} = public_key) do 51 | signature = der_ecdsa_signature(signature) 52 | 53 | :public_key.verify(payload, :sha512, signature, public_key) 54 | end 55 | 56 | def verify!(payload, "hs2019", signature, hmac_secret) when is_binary(hmac_secret) do 57 | signature == hmac(:sha512, hmac_secret, payload) 58 | end 59 | 60 | def verify!(payload, "rsa-sha256", signature, rsa_public_key() = public_key) do 61 | # Use PKCS1-v1_5 padding (default) 62 | :public_key.verify(payload, :sha256, signature, public_key) 63 | end 64 | 65 | def verify!(payload, "rsa-sha1", signature, rsa_public_key() = public_key) do 66 | # Use PKCS1-v1_5 padding (default) 67 | :public_key.verify(payload, :sha, signature, public_key) 68 | end 69 | 70 | def verify!(payload, "ecdsa-sha256", signature, {_point, _ecpk_parameters} = public_key) do 71 | signature = der_ecdsa_signature(signature) 72 | 73 | :public_key.verify(payload, :sha256, signature, public_key) 74 | end 75 | 76 | def verify!(payload, "hmac-sha256", signature, hmac_secret) when is_binary(hmac_secret) do 77 | signature == hmac(:sha256, hmac_secret, payload) 78 | end 79 | 80 | def verify(payload, algorithm, signature, public_key) do 81 | {:ok, verify!(payload, algorithm, signature, public_key)} 82 | rescue 83 | _ -> {:error, "bad algorithm or key"} 84 | end 85 | 86 | # Not all ECDSA implementations wrap the signature in ASN.1; some just 87 | # return the raw curve coordinates (r and s) concattenated as binaries; 88 | # so we convert to ASN.1 DER format if necessary 89 | defp der_ecdsa_signature(signature) do 90 | case is_der_ecdsa_signature?(signature) do 91 | true -> 92 | signature 93 | 94 | false -> 95 | size = signature |> byte_size() |> div(2) 96 | <> = signature 97 | :public_key.der_encode(:"ECDSA-Sig-Value", ecdsa_signature(r: r, s: s)) 98 | end 99 | end 100 | 101 | defp is_der_ecdsa_signature?(signature) do 102 | _ = :public_key.der_decode(:"ECDSA-Sig-Value", signature) 103 | true 104 | rescue 105 | MatchError -> false 106 | end 107 | 108 | @doc """ 109 | Generates a signature. Raises in case of an error. 110 | """ 111 | def sign!(payload, "hs2019", rsa_private_key(publicExponent: e, modulus: n, privateExponent: d)) do 112 | # Use PSS padding; requires workaround for https://bugs.erlang.org/browse/ERL-878 113 | :crypto.sign(:rsa, :sha512, payload, [e, n, d], rsa_padding: :rsa_pkcs1_pss_padding) 114 | end 115 | 116 | def sign!(payload, "hs2019", ec_private_key() = private_key) do 117 | :public_key.sign(payload, :sha512, private_key) 118 | end 119 | 120 | def sign!(payload, "hs2019", hmac_secret) when is_binary(hmac_secret) do 121 | hmac(:sha512, hmac_secret, payload) 122 | end 123 | 124 | def sign!(payload, "rsa-sha256", rsa_private_key() = private_key) do 125 | # Use PKCS1-v1_5 padding (default) 126 | :public_key.sign(payload, :sha256, private_key) 127 | end 128 | 129 | def sign!(payload, "rsa-sha1", rsa_private_key() = private_key) do 130 | # Use PKCS1-v1_5 padding (default) 131 | :public_key.sign(payload, :sha, private_key) 132 | end 133 | 134 | def sign!(payload, "ecdsa-sha256", ec_private_key() = private_key) do 135 | :public_key.sign(payload, :sha256, private_key) 136 | end 137 | 138 | def sign!(payload, "hmac-sha256", hmac_secret) when is_binary(hmac_secret) do 139 | hmac(:sha256, hmac_secret, payload) 140 | end 141 | 142 | @doc """ 143 | Generates a signature. 144 | 145 | Returns `{:ok, signature}` or `{:error, reason}`. 146 | """ 147 | def sign(payload, algorithm, private_key) do 148 | {:ok, sign!(payload, algorithm, private_key)} 149 | rescue 150 | _ -> {:error, "bad algorithm or key"} 151 | end 152 | 153 | if Code.ensure_loaded?(:crypto) and function_exported?(:crypto, :mac, 4) do 154 | defp hmac(type, hmac_secret, payload) do 155 | :crypto.mac(:hmac, type, hmac_secret, payload) 156 | end 157 | else 158 | defp hmac(type, hmac_secret, payload) do 159 | :crypto.hmac(type, hmac_secret, payload) 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /test/plug_signature/callbacks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.CallbacksTest do 2 | use ExUnit.Case 3 | doctest PlugSignature.Callback 4 | 5 | import PlugSignature.ConnTest 6 | import ExUnit.CaptureLog 7 | require Logger 8 | 9 | defmodule Callback do 10 | # Sample callback module for use in tests 11 | require Logger 12 | 13 | @behaviour PlugSignature.Callback 14 | 15 | @private_ec X509.PrivateKey.new_ec(:secp256r1) 16 | @public_ec X509.PublicKey.derive(@private_ec) 17 | 18 | @impl true 19 | def client_lookup("no_such_client", _algorithm, _conn) do 20 | {:error, "no client for key_id"} 21 | end 22 | 23 | def client_lookup("key_id", "hs2019", _conn) do 24 | {:ok, "some client", @public_ec} 25 | end 26 | 27 | def client_lookup("key_id", _algorithm, _conn) do 28 | {:error, "no credentials for algorithm"} 29 | end 30 | 31 | def success(conn, client) do 32 | Logger.info("Signature auth successful; client='#{client}'") 33 | Plug.Conn.assign(conn, :client, client) 34 | end 35 | 36 | def failure(conn, "no client for key_id", algorithm, _headers) do 37 | Logger.warn("Invalid key ID (#{algorithm})") 38 | 39 | conn 40 | |> Plug.Conn.send_resp(403, "") 41 | |> Plug.Conn.halt() 42 | end 43 | 44 | def private(), do: @private_ec 45 | end 46 | 47 | setup_all do 48 | [config: [callback_module: Callback]] 49 | end 50 | 51 | describe "PlugSignature.Callback.client_lookup/2" do 52 | test "success", %{config: config} do 53 | priv = config[:callback_module].private() 54 | 55 | conn = 56 | conn() 57 | |> with_signature(priv, "key_id", config) 58 | |> PlugSignature.call(PlugSignature.init(config)) 59 | 60 | refute conn.halted 61 | end 62 | 63 | test "bad key_id", %{config: config} do 64 | priv = config[:callback_module].private() 65 | 66 | scenario = fn -> 67 | conn = 68 | conn() 69 | |> with_signature(priv, "no_such_client", config) 70 | |> PlugSignature.call(PlugSignature.init(config)) 71 | 72 | assert conn.halted 73 | assert conn.status == 401 74 | end 75 | 76 | assert capture_log(scenario) =~ "no client for key_id" 77 | end 78 | 79 | test "bad algorithm", %{config: config} do 80 | config = Keyword.put(config, :algorithms, ["ecdsa-sha256"]) 81 | priv = config[:callback_module].private() 82 | 83 | scenario = fn -> 84 | conn = 85 | conn() 86 | |> with_signature(priv, "key_id", config) 87 | |> PlugSignature.call(PlugSignature.init(config)) 88 | 89 | assert conn.halted 90 | assert conn.status == 401 91 | end 92 | 93 | assert capture_log(scenario) =~ "no credentials for algorithm" 94 | end 95 | end 96 | 97 | describe "on_success" do 98 | test "{m, f, a}", %{config: config} do 99 | config = Keyword.put(config, :on_success, {Callback, :success, []}) 100 | priv = config[:callback_module].private() 101 | 102 | scenario = fn -> 103 | conn = 104 | conn() 105 | |> with_signature(priv, "key_id", config) 106 | |> PlugSignature.call(PlugSignature.init(config)) 107 | 108 | refute conn.halted 109 | assert conn.assigns[:client] == "some client" 110 | end 111 | 112 | assert capture_log(scenario) =~ "client='some client'" 113 | end 114 | 115 | test "anonymous/2", %{config: config} do 116 | config = 117 | Keyword.put(config, :on_success, fn conn, client -> 118 | Plug.Conn.assign(conn, :client, client) 119 | end) 120 | 121 | priv = config[:callback_module].private() 122 | 123 | conn = 124 | conn() 125 | |> with_signature(priv, "key_id", config) 126 | |> PlugSignature.call(PlugSignature.init(config)) 127 | 128 | refute conn.halted 129 | assert conn.assigns[:client] == "some client" 130 | end 131 | 132 | test "anonymous/1", %{config: config} do 133 | config = 134 | Keyword.put(config, :on_success, fn conn -> 135 | Logger.info("Signature auth successful") 136 | Plug.Conn.assign(conn, :success, true) 137 | end) 138 | 139 | priv = config[:callback_module].private() 140 | 141 | scenario = fn -> 142 | conn = 143 | conn() 144 | |> with_signature(priv, "key_id", config) 145 | |> PlugSignature.call(PlugSignature.init(config)) 146 | 147 | refute conn.halted 148 | assert conn.assigns[:success] == true 149 | end 150 | 151 | assert capture_log(scenario) =~ "Signature auth successful" 152 | end 153 | end 154 | 155 | describe "on_failure" do 156 | test "{m, f, a}", %{config: config} do 157 | config = Keyword.put(config, :on_failure, {Callback, :failure, []}) 158 | priv = config[:callback_module].private() 159 | 160 | scenario = fn -> 161 | conn = 162 | conn() 163 | |> with_signature(priv, "no_such_client", config) 164 | |> PlugSignature.call(PlugSignature.init(config)) 165 | 166 | assert conn.halted 167 | assert conn.status == 403 168 | end 169 | 170 | refute capture_log(scenario) =~ "no client for key_id" 171 | end 172 | 173 | test "anonymous/4", %{config: config} do 174 | config = 175 | Keyword.put(config, :on_failure, fn conn, reason, algorithm, headers -> 176 | Plug.Conn.assign(conn, :auth, {:failed, reason, algorithm, headers}) 177 | end) 178 | 179 | priv = config[:callback_module].private() 180 | 181 | scenario = fn -> 182 | conn = 183 | conn() 184 | |> with_signature(priv, "no_such_client", config) 185 | |> PlugSignature.call(PlugSignature.init(config)) 186 | 187 | refute conn.halted 188 | assert {:failed, "no client for key_id", "hs2019", "(created)"} = conn.assigns[:auth] 189 | end 190 | 191 | refute capture_log(scenario) =~ "no client for key_id" 192 | end 193 | 194 | test "nil", %{config: config} do 195 | config = Keyword.put(config, :on_failure, nil) 196 | priv = config[:callback_module].private() 197 | 198 | scenario = fn -> 199 | conn = 200 | conn() 201 | |> with_signature(priv, "no_such_client", config) 202 | |> PlugSignature.call(PlugSignature.init(config)) 203 | 204 | refute conn.halted 205 | end 206 | 207 | refute capture_log(scenario) =~ "no client for key_id" 208 | end 209 | end 210 | 211 | defp conn() do 212 | Plug.Test.conn(:get, "/") 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PlugSignature 2 | 3 | [![Github.com](https://github.com/voltone/plug_signature/workflows/CI/badge.svg)](https://github.com/voltone/plug_signature/actions) 4 | [![Hex.pm](https://img.shields.io/hexpm/v/plug_signature.svg)](https://hex.pm/packages/plug_signature) 5 | [![Hexdocs.pm](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/plug_signature/) 6 | [![Hex.pm](https://img.shields.io/hexpm/dt/plug_signature.svg)](https://hex.pm/packages/plug_signature) 7 | [![Hex.pm](https://img.shields.io/hexpm/l/plug_signature.svg)](https://hex.pm/packages/plug_signature) 8 | [![Github.com](https://img.shields.io/github/last-commit/voltone/plug_signature.svg)](https://github.com/voltone/plug_signature/commits/master) 9 | 10 | Plug for verifying request signatures according to the IETF HTTP signatures 11 | [draft specification](https://tools.ietf.org/html/draft-cavage-http-signatures-12). 12 | 13 | Supports the following algorithms: 14 | 15 | * "hs2019", using ECDSA, RSASSA-PSS or HMAC 16 | * "rsa-sha256", using RSASSA-PKCS1-v1_5 17 | * "ecdsa-sha256" 18 | * "hmac-sha256" 19 | * "rsa-sha1", using RSASSA-PKCS1-v1_5 20 | 21 | Development and public release of this package were made possible by 22 | [Bluecode](https://bluecode.com/). 23 | 24 | The HTTP Date header parsing module was vendored from 25 | [cowlib](https://github.com/ninenines/cowlib), due to build issues that 26 | prevented use of the package as a dependency. Cowlib is copyright (c) 27 | 2013-2018, Loïc Hoguin 28 | 29 | ## Usage 30 | 31 | Use `PlugSignature` in a Phoenix (or other Plug-based) application. 32 | 33 | Requests with a valid signature are allowed to proceed while all others are 34 | rejected. Both the success and the failure behaviour can be customized. 35 | 36 | `PlugSignature` requires a callback module that implements the 37 | `PlugSignature.Callback` behaviour. In a Phoenix application this would 38 | typically live in a 'context' module, and it might look something like this: 39 | 40 | ```elixir 41 | defmodule MyApp.Auth do 42 | import Ecto.Query, only: [from: 2] 43 | 44 | alias MyApp.Repo 45 | alias MyApp.Auth.AccessKey 46 | 47 | @behaviour PlugSignature.Callback 48 | 49 | @impl true 50 | def client_lookup(key_id, "hs2019", _conn) do 51 | query = from a in AccessKey, 52 | where: a.key_id == ^key_id, 53 | preload: :client 54 | 55 | case Repo.one(query) do 56 | nil -> 57 | {:error, "Invalid access key ID: #{key_id}"} 58 | 59 | {:ok, %AccessKey{revoked: true}} -> 60 | {:error, "Access key revoked: #{key_id}"} 61 | 62 | {:ok, %AccessKey{public_key: pem, client: client}} -> 63 | public_key = plug_signature.PublicKey.from_pem!(pem) 64 | {:ok, client, public_key} 65 | end 66 | end 67 | end 68 | ``` 69 | 70 | To enable verification of the request body, through the HTTP Digest header, 71 | add `PlugBodyDigest` from the [plug_body_digest](https://hex.pm/packages/plug_body_digest) 72 | package, e.g. to the application's Phoenix Endpoint: 73 | 74 | ```elixir 75 | defmodule MyAppWeb.Endpoint do 76 | # ... 77 | 78 | plug Plug.Parsers, 79 | parsers: [:urlencoded, :multipart, :json], 80 | pass: ["*/*"], 81 | json_decoder: Phoenix.json_library(), 82 | body_reader: {PlugBodyDigest, :digest_body_reader, []} 83 | 84 | plug PlugBodyDigest 85 | end 86 | ``` 87 | 88 | Finally, add `PlugSignature`, for instance to a Phoenix Router pipeline: 89 | 90 | ```elixir 91 | defmodule MyAppWeb.Router do 92 | # ... 93 | 94 | pipeline :api do 95 | plug :accepts, ["json"] 96 | plug PlugSignature, 97 | callback_module: MyApp.Auth, 98 | headers: "(request-target) (created) host digest", 99 | on_success: {PlugSignature, :assign_client, [:client]} 100 | end 101 | 102 | # ... 103 | end 104 | ``` 105 | 106 | Alternatively it may be used inside a controller's pipeline, possibly with 107 | guards: 108 | 109 | ```elixir 110 | defmodule MyAppWeb.SomeController do 111 | use MyAppWeb, :controller 112 | 113 | plug PlugSignature, [ 114 | callback_module: MyApp.Auth, 115 | headers: "(request-target) (created) host digest" 116 | ] when not action in [:show, :index] 117 | 118 | # ... 119 | end 120 | ``` 121 | 122 | The directory `plug_signature_example` in the package source repository 123 | contains a minimal functional sample application, implemented as a simple Plug 124 | server that echos back the request parameters after signature authentication. 125 | 126 | ## Client implementation 127 | 128 | The sample application includes clients written in Elixir, using Tesla 129 | middleware, and as a shell script, using OpenSSL and cURL. 130 | 131 | ## Installation 132 | 133 | Add `plug_signature` to your list of dependencies in `mix.exs` (and consider 134 | adding `plug_body_digest` as well): 135 | 136 | ```elixir 137 | def deps do 138 | [ 139 | {:plug_body_digest, "~> 0.5.0"}, 140 | {:plug_signature, "~> 0.6.0"} 141 | ] 142 | end 143 | ``` 144 | 145 | Documentation can be found at [https://hexdocs.pm/plug_signature](https://hexdocs.pm/plug_signature). 146 | 147 | ## License 148 | 149 | Copyright (c) 2019, Bram Verburg 150 | All rights reserved. 151 | 152 | Redistribution and use in source and binary forms, with or without 153 | modification, are permitted provided that the following conditions are met: 154 | 155 | * Redistributions of source code must retain the above copyright notice, this 156 | list of conditions and the following disclaimer. 157 | 158 | * Redistributions in binary form must reproduce the above copyright notice, 159 | this list of conditions and the following disclaimer in the documentation 160 | and/or other materials provided with the distribution. 161 | 162 | * Neither the name of the copyright holder nor the names of its contributors 163 | may be used to endorse or promote products derived from this software 164 | without specific prior written permission. 165 | 166 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 167 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 168 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 169 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 170 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 171 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 172 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 173 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 174 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 175 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 176 | -------------------------------------------------------------------------------- /lib/plug_signature/config.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.ConfigError do 2 | @moduledoc """ 3 | Exception raised in case of an invalid configuration. 4 | """ 5 | 6 | defexception [:message] 7 | end 8 | 9 | defmodule PlugSignature.Config do 10 | @moduledoc false 11 | 12 | @default_algorithms ["hs2019"] 13 | @hs2019_default_headers "(created)" 14 | @legacy_default_headers "date" 15 | @default_validity -300..30 16 | @default_on_success nil 17 | @default_on_failure {PlugSignature, :failure, []} 18 | 19 | @legacy_algorithms ["rsa-sha1", "rsa-sha256", "hmac-sha256", "ecdsa-sha256"] 20 | 21 | @type on_success :: 22 | nil 23 | | {module(), atom(), [term()]} 24 | | (Plug.Conn.t(), term() -> Plug.Conn.t()) 25 | | (Plug.Conn.t() -> Plug.Conn.t()) 26 | @type on_failure :: 27 | nil 28 | | {module(), atom(), [term()]} 29 | | (Plug.Conn.t(), String.t(), String.t(), [String.t()] -> Plug.Conn.t()) 30 | | (Plug.Conn.t(), String.t(), String.t() -> Plug.Conn.t()) 31 | | (Plug.Conn.t(), String.t() -> Plug.Conn.t()) 32 | 33 | # Used to validate the `PlugSignature` configuration; since the arguments to 34 | # the `PlugSignature.init/1` are typically evaluated at compile-time, this 35 | # can help catch configuration issues early 36 | @spec new(Keyword.t()) :: [ 37 | {:callback_module, Module.t()} 38 | | {:header_name, String.t()} 39 | | {:default_algorithm, String.t()} 40 | | {:algorithms, map()} 41 | | {:on_success, on_success()} 42 | | {:on_failure, on_failure()} 43 | ] 44 | def new(opts) do 45 | callback_module = 46 | Keyword.get(opts, :callback_module) || 47 | raise PlugSignature.ConfigError, "missing mandatory option `:callback_module`" 48 | 49 | header_name = Keyword.get(opts, :header_name, "authorization") |> String.downcase() 50 | 51 | algorithms = Keyword.get(opts, :algorithms, @default_algorithms) 52 | 53 | if algorithms == [] do 54 | raise PlugSignature.ConfigError, "algorithm list is empty" 55 | end 56 | 57 | on_success = opts |> Keyword.get(:on_success, @default_on_success) |> validate_on_success() 58 | on_failure = opts |> Keyword.get(:on_failure, @default_on_failure) |> validate_on_failure() 59 | 60 | algorithm_config = 61 | algorithms 62 | |> Enum.map(&{&1, opts_for_algorithm(&1, opts)}) 63 | |> Enum.into(%{}) 64 | 65 | [ 66 | callback_module: callback_module, 67 | header_name: header_name, 68 | default_algorithm: hd(algorithms), 69 | algorithms: algorithm_config, 70 | on_success: on_success, 71 | on_failure: on_failure 72 | ] 73 | end 74 | 75 | # Expand and validate options per algorithm; raise on unknown algorithm or 76 | # invalid configuration 77 | 78 | defp opts_for_algorithm("hs2019", opts) do 79 | headers = Keyword.get(opts, :headers, @hs2019_default_headers) 80 | header_list = headers |> String.downcase() |> String.split(" ", trim: true) 81 | validate_header_list!(header_list, "hs2019") 82 | validity = Keyword.get(opts, :validity, @default_validity) 83 | 84 | case validity do 85 | :infinity -> 86 | if "(expires)" not in header_list do 87 | raise PlugSignature.ConfigError, "missing pseudo-header `(expires)` in header list" 88 | end 89 | 90 | _from.._to -> 91 | :ok 92 | 93 | _otherwise -> 94 | raise PlugSignature.ConfigError, "validity must be a range, or `:infinity`" 95 | end 96 | 97 | %{ 98 | headers: headers, 99 | header_list: header_list, 100 | default_headers: @hs2019_default_headers, 101 | validity: validity, 102 | check_date_header: "date" in header_list 103 | } 104 | end 105 | 106 | defp opts_for_algorithm(algorithm, opts) when algorithm in @legacy_algorithms do 107 | legacy_opts = Keyword.get(opts, :legacy, []) 108 | 109 | headers = 110 | Keyword.get(legacy_opts, :headers, Keyword.get(opts, :headers, @legacy_default_headers)) 111 | 112 | header_list = headers |> String.downcase() |> String.split(" ", trim: true) 113 | validate_header_list!(header_list, algorithm) 114 | 115 | validity = 116 | Keyword.get(legacy_opts, :validity, Keyword.get(opts, :validity, @default_validity)) 117 | 118 | case validity do 119 | :infinity -> 120 | raise PlugSignature.ConfigError, "cannot use infinite validity with legacy algorithms" 121 | 122 | _from.._to -> 123 | :ok 124 | 125 | _otherwise -> 126 | raise PlugSignature.ConfigError, "validity must be a range" 127 | end 128 | 129 | %{ 130 | headers: headers, 131 | header_list: header_list, 132 | default_headers: @legacy_default_headers, 133 | validity: validity, 134 | check_date_header: "date" in header_list 135 | } 136 | end 137 | 138 | defp opts_for_algorithm(algorithm, _opts) do 139 | raise PlugSignature.ConfigError, "unknown algorithm: '#{algorithm}'" 140 | end 141 | 142 | # Make sure all header list values are legal HTTP header names or known 143 | # pseudo-headers for the specific algorithm 144 | 145 | defp validate_header_list!([], _algorithm) do 146 | raise PlugSignature.ConfigError, "header list must not be empty" 147 | end 148 | 149 | defp validate_header_list!(headers, algorithm) do 150 | headers 151 | |> Enum.reject(&is_standard_header?/1) 152 | |> validate_pseudo_header_list!(algorithm) 153 | end 154 | 155 | defp validate_pseudo_header_list!([], _algorithm), do: :ok 156 | 157 | defp validate_pseudo_header_list!(["(request-target)" | more], algorithm) do 158 | validate_pseudo_header_list!(more, algorithm) 159 | end 160 | 161 | defp validate_pseudo_header_list!(["(created)" | more], "hs2019" = algorithm) do 162 | validate_pseudo_header_list!(more, algorithm) 163 | end 164 | 165 | defp validate_pseudo_header_list!(["(expires)" | more], "hs2019" = algorithm) do 166 | validate_pseudo_header_list!(more, algorithm) 167 | end 168 | 169 | defp validate_pseudo_header_list!([header | _], _algorithm) do 170 | raise PlugSignature.ConfigError, "invalid header '#{header}'" 171 | end 172 | 173 | defp is_standard_header?(header) do 174 | header =~ ~r/^[!#-'*+\-.0-9^-z|~]+$/ 175 | end 176 | 177 | defp validate_on_success(nil), do: nil 178 | defp validate_on_success({m, f, a}) when is_atom(m) and is_atom(f) and is_list(a), do: {m, f, a} 179 | defp validate_on_success(fun) when is_function(fun, 2) or is_function(fun, 1), do: fun 180 | 181 | defp validate_on_success(_) do 182 | raise PlugSignature.ConfigError, "invalid value for `:on_success`" 183 | end 184 | 185 | defp validate_on_failure(nil), do: nil 186 | defp validate_on_failure({m, f, a}) when is_atom(m) and is_atom(f) and is_list(a), do: {m, f, a} 187 | 188 | defp validate_on_failure(fun) 189 | when is_function(fun, 4) or is_function(fun, 2) or is_function(fun, 1), 190 | do: fun 191 | 192 | defp validate_on_failure(_) do 193 | raise PlugSignature.ConfigError, "invalid value for `:on_failure`" 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /lib/plug_signature/conn_test.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.ConnTest do 2 | @moduledoc """ 3 | Helpers for testing HTTP signatures with Plug/Phoenix. 4 | """ 5 | 6 | import Plug.Conn 7 | 8 | alias PlugSignature.Crypto 9 | 10 | # This module injects the signature calculation right before invoking the 11 | # Phoenix Endpoint. This allows us to reuse Phoenix.ConnTest helpers for 12 | # building the Plug.Conn struct, and add the signature once all the conn 13 | # parameters have been initialized. 14 | defmodule EndpointWrapper do 15 | @moduledoc false 16 | 17 | def init(args), do: args 18 | 19 | def call(%Plug.Conn{private: %{PlugSignature.ConnTest => sign_opts}} = conn, args) do 20 | endpoint = Keyword.fetch!(sign_opts, :endpoint) 21 | 22 | conn 23 | |> PlugSignature.ConnTest.with_signature(sign_opts[:key], sign_opts[:key_id], sign_opts) 24 | |> endpoint.call(args) 25 | end 26 | end 27 | 28 | @doc """ 29 | Adds an Authorization header with a signature. Requires a secret (RSA 30 | private key, EC private key or HMAC shared secret) and key ID. 31 | 32 | ## Options 33 | 34 | * `:algorithms` - the HTTP signature algorithms to be used; list with 35 | one or more of: 36 | 37 | * `"hs2019"` (default) 38 | * `"rsa-sha256"` 39 | * `"rsa-sha1"` 40 | * `"ecdsa-sha256"` 41 | * `"hmac-sha256"` 42 | 43 | The first algorithm in the list will be used to generate the signature 44 | (it is a list to allow the core set of configuration options to be 45 | shared with `PlugSignature` in tests). 46 | * `:headers` - set the list of HTTP (pseudo) headers to sign; defaults to 47 | "(created)" (which is only valid when the algorithm is "hs2019") 48 | * `:request_target` - explicitly set the request target; by default it is 49 | built from the Plug.Conn struct (method, request_path and query) 50 | * `:age` - shift the HTTP Date header and the signature's 'created' 51 | parameter by the given number of seconds into the past; defaults to 0 52 | * `:created` - set the signature's 'created' parameter (overrides `:age`); 53 | set to a empty string to omit the 'created' parameter 54 | * `:date` - set the HTTP Date header (overrides `:age`) 55 | * `:expires_in` - if set, adds an 'expires' parameter with a timestamp 56 | the given number of seconds in the future 57 | * `:expires` - set the signature's 'expires' parameter (overrides 58 | `:expires_in`) 59 | * `:key_id_override` - override the value for `keyId` in the Authorization 60 | header 61 | * `:algorithm_override` - override the value for the signature's 62 | 'algorithm' parameter in the Authorization header 63 | * `:signature_override` - override the signature value sent in the 64 | Authorization header 65 | * `:headers_override` - override the value for the signature's 'headers' 66 | parameter in the Authorization header 67 | * `:created_override` - override the value for the signature's 'created' 68 | parameter in the Authorization header 69 | * `:expires_override` - override the value for the signature's 'expires' 70 | parameter in the Authorization header 71 | """ 72 | def with_signature(conn, key, key_id, opts \\ []) do 73 | age = Keyword.get(opts, :age, 0) 74 | created = Keyword.get_lazy(opts, :created, fn -> created(age) end) 75 | date = Keyword.get_lazy(opts, :date, fn -> http_date(age) end) 76 | expires_in = Keyword.get(opts, :expires_in, nil) 77 | expires = Keyword.get_lazy(opts, :expires, fn -> expires(expires_in) end) 78 | algorithms = Keyword.get(opts, :algorithms, ["hs2019"]) 79 | algorithm = hd(algorithms) 80 | headers = Keyword.get(opts, :headers, default_headers(algorithm)) 81 | header_name = Keyword.get(opts, :header_name, "authorization") 82 | 83 | conn = 84 | conn 85 | |> put_req_header("date", date) 86 | 87 | request_target = 88 | Keyword.get_lazy(opts, :request_target, fn -> 89 | method = conn.method |> to_string |> String.downcase() 90 | 91 | case conn.query_string do 92 | "" -> 93 | "#{method} #{conn.request_path}" 94 | 95 | query -> 96 | "#{method} #{conn.request_path}?#{query}" 97 | end 98 | end) 99 | 100 | to_be_signed = 101 | Keyword.get_lazy(opts, :to_be_signed, fn -> 102 | headers 103 | |> String.split(" ") 104 | |> Enum.map_join("\n", fn 105 | "(request-target)" -> "(request-target): #{request_target}" 106 | "(created)" -> "(created): #{created}" 107 | "(expires)" -> "(expires): #{expires}" 108 | "date" -> "date: #{date}" 109 | "host" -> "host: #{conn.host}" 110 | header -> "#{header}: #{get_req_header(conn, header) |> Enum.join(",")}" 111 | end) 112 | end) 113 | 114 | signature = 115 | Keyword.get_lazy(opts, :signature, fn -> 116 | {:ok, signature} = Crypto.sign(to_be_signed, algorithm, key) 117 | Base.encode64(signature) 118 | end) 119 | 120 | signature_string = 121 | [ 122 | ~s(keyId="#{Keyword.get(opts, :key_id_override, key_id)}"), 123 | ~s(signature="#{Keyword.get(opts, :signature_override, signature)}"), 124 | ~s(headers="#{Keyword.get(opts, :headers_override, headers)}"), 125 | ~s(created=#{Keyword.get(opts, :created_override, created)}), 126 | ~s(expires=#{Keyword.get(opts, :expires_override, expires)}), 127 | ~s(algorithm=#{Keyword.get(opts, :algorithm_override, algorithm)}) 128 | ] 129 | |> Enum.reject(&Regex.match?(~r/^[^=]+=("")?$/, &1)) 130 | |> Enum.join(",") 131 | 132 | case header_name do 133 | "authorization" -> 134 | put_req_header(conn, "authorization", "Signature #{signature_string}") 135 | 136 | _ -> 137 | put_req_header(conn, header_name, signature_string) 138 | end 139 | end 140 | 141 | defp default_headers("hs2019"), do: "(created)" 142 | defp default_headers(_legacy), do: "date" 143 | 144 | @doc """ 145 | Add an HTTP Digest header (RFC3230, section 4.3.2). 146 | 147 | When the request body is passed in as a binary, a SHA-256 digest of the body 148 | is calculated and added as part of the header. Alternatively, a map of 149 | digest types and values may be provided. 150 | """ 151 | @deprecated "Use PlugBodyDigest.ConnTest.with_digest/2, from the plug_body_digest package" 152 | def with_digest(conn, body_or_digests) 153 | 154 | def with_digest(conn, digests) when is_map(digests) do 155 | digest_header = 156 | digests 157 | |> Enum.map_join(",", fn {alg, value} -> "#{alg}=#{value}" end) 158 | 159 | put_req_header(conn, "digest", digest_header) 160 | end 161 | 162 | def with_digest(conn, body) do 163 | with_digest(conn, %{"SHA-256" => :crypto.hash(:sha256, body) |> Base.encode64()}) 164 | end 165 | 166 | @http_methods [:get, :post, :put, :patch, :delete, :options, :connect, :trace, :head] 167 | 168 | for method <- @http_methods do 169 | function = :"signed_#{method}" 170 | 171 | @doc """ 172 | Makes a signed request to a Phoenix endpoint. 173 | 174 | The last argument is a keyword list with signature options, with the 175 | `:key` and `:key_id` options being mandatory. For other options, please 176 | see `with_signature/4`. 177 | 178 | Requires Phoenix.ConnTest. 179 | """ 180 | defmacro unquote(function)(conn, path, params_or_body \\ nil, sign_opts) do 181 | raise_on_missing_phoenix_conntest!() 182 | 183 | method = unquote(method) 184 | 185 | quote bind_quoted: [ 186 | conn: conn, 187 | method: method, 188 | path: path, 189 | params_or_body: params_or_body, 190 | sign_opts: sign_opts 191 | ] do 192 | conn 193 | |> put_private(PlugSignature.ConnTest, [{:endpoint, @endpoint} | sign_opts]) 194 | |> Phoenix.ConnTest.dispatch( 195 | PlugSignature.ConnTest.EndpointWrapper, 196 | method, 197 | path, 198 | params_or_body 199 | ) 200 | end 201 | end 202 | end 203 | 204 | defp created(nil), do: "" 205 | 206 | defp created(age) do 207 | now = DateTime.utc_now() |> DateTime.to_unix() 208 | to_string(now - age) 209 | end 210 | 211 | defp http_date(age) do 212 | NaiveDateTime.utc_now() 213 | |> NaiveDateTime.add(0 - age) 214 | |> NaiveDateTime.to_erl() 215 | |> :plug_signature_http_date.rfc7231() 216 | end 217 | 218 | defp expires(nil), do: "" 219 | 220 | defp expires(validity) do 221 | now = DateTime.utc_now() |> DateTime.to_unix() 222 | to_string(now + validity) 223 | end 224 | 225 | defp raise_on_missing_phoenix_conntest! do 226 | Code.ensure_loaded?(Phoenix.ConnTest) || 227 | raise "endpoint testing requirs Phoenix.ConnTest" 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /test/plug_signature/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.ConfigTest do 2 | use ExUnit.Case 3 | doctest PlugSignature.Config 4 | 5 | describe "common settings" do 6 | test "requires callback_module" do 7 | assert_raise PlugSignature.ConfigError, "missing mandatory option `:callback_module`", fn -> 8 | PlugSignature.init([]) 9 | end 10 | end 11 | 12 | test "defaults to 'hs2019'" do 13 | opts = PlugSignature.init(callback_module: SomeModule) 14 | assert "hs2019" = Keyword.get(opts, :default_algorithm) 15 | assert ["hs2019"] = opts |> Keyword.get(:algorithms) |> Map.keys() 16 | end 17 | 18 | test "fails on empty algorithms" do 19 | assert_raise PlugSignature.ConfigError, "algorithm list is empty", fn -> 20 | PlugSignature.init( 21 | callback_module: SomeModule, 22 | algorithms: [] 23 | ) 24 | end 25 | end 26 | 27 | test "supports all algorithms" do 28 | all = ["rsa-sha256", "rsa-sha1", "ecdsa-sha256", "hmac-sha256", "hs2019"] 29 | 30 | opts = 31 | PlugSignature.init( 32 | callback_module: SomeModule, 33 | algorithms: all 34 | ) 35 | 36 | assert "rsa-sha256" = Keyword.get(opts, :default_algorithm) 37 | 38 | Enum.each(all, fn algorithm -> 39 | assert Map.has_key?(Keyword.get(opts, :algorithms), algorithm) 40 | end) 41 | end 42 | 43 | test "fails on unknown algorithm" do 44 | assert_raise PlugSignature.ConfigError, "unknown algorithm: 'ecdsa-sha1'", fn -> 45 | PlugSignature.init( 46 | callback_module: SomeModule, 47 | algorithms: ["ecdsa-sha1"] 48 | ) 49 | end 50 | end 51 | 52 | test "checks on_success value" do 53 | assert_raise PlugSignature.ConfigError, "invalid value for `:on_success`", fn -> 54 | PlugSignature.init( 55 | callback_module: SomeModule, 56 | on_success: fn -> :ok end 57 | ) 58 | end 59 | end 60 | 61 | test "checks on_failure value" do 62 | assert_raise PlugSignature.ConfigError, "invalid value for `:on_failure`", fn -> 63 | PlugSignature.init( 64 | callback_module: SomeModule, 65 | on_failure: {SomeModule, :failure} 66 | ) 67 | end 68 | end 69 | end 70 | 71 | describe "hs2019" do 72 | test "allows all pseudo-headers" do 73 | opts = 74 | PlugSignature.init( 75 | callback_module: SomeModule, 76 | algorithms: ["hs2019"], 77 | headers: "(created) (expires) (request-target)" 78 | ) 79 | 80 | assert %{"hs2019" => alg_opts} = Keyword.get(opts, :algorithms) 81 | assert "(created) (expires) (request-target)" = alg_opts.headers 82 | assert ["(created)", "(expires)", "(request-target)"] = alg_opts.header_list 83 | end 84 | 85 | test "disallows unknown pseudo-headers" do 86 | assert_raise PlugSignature.ConfigError, "invalid header '(request-method)'", fn -> 87 | PlugSignature.init( 88 | callback_module: SomeModule, 89 | algorithms: ["hs2019"], 90 | headers: "(created) (request-method)" 91 | ) 92 | end 93 | end 94 | 95 | test "allows any legal HTTP headers" do 96 | opts = 97 | PlugSignature.init( 98 | callback_module: SomeModule, 99 | algorithms: ["hs2019"], 100 | headers: "X-Some-Header MD5-digest weird!" 101 | ) 102 | 103 | assert %{"hs2019" => alg_opts} = Keyword.get(opts, :algorithms) 104 | assert "X-Some-Header MD5-digest weird!" = alg_opts.headers 105 | assert ["x-some-header", "md5-digest", "weird!"] = alg_opts.header_list 106 | end 107 | 108 | test "disallows illegal HTTP headers" do 109 | assert_raise PlugSignature.ConfigError, "invalid header 'illegal:header'", fn -> 110 | PlugSignature.init( 111 | callback_module: SomeModule, 112 | algorithms: ["hs2019"], 113 | headers: "(created) illegal:header" 114 | ) 115 | end 116 | end 117 | 118 | test "allows infinite validity, with (expires)" do 119 | opts = 120 | PlugSignature.init( 121 | callback_module: SomeModule, 122 | algorithms: ["hs2019"], 123 | headers: "(request-target) (expires)", 124 | validity: :infinity 125 | ) 126 | 127 | assert %{"hs2019" => alg_opts} = Keyword.get(opts, :algorithms) 128 | assert :infinity = alg_opts.validity 129 | end 130 | 131 | test "disallows infinite validity without (expires)" do 132 | assert_raise PlugSignature.ConfigError, 133 | "missing pseudo-header `(expires)` in header list", 134 | fn -> 135 | PlugSignature.init( 136 | callback_module: SomeModule, 137 | algorithms: ["hs2019"], 138 | headers: "(request-target) (created)", 139 | validity: :infinity 140 | ) 141 | end 142 | end 143 | 144 | test "checks validity type" do 145 | assert_raise PlugSignature.ConfigError, 146 | "validity must be a range, or `:infinity`", 147 | fn -> 148 | PlugSignature.init( 149 | callback_module: SomeModule, 150 | algorithms: ["hs2019"], 151 | headers: "(request-target) (created)", 152 | validity: 300 153 | ) 154 | end 155 | end 156 | end 157 | 158 | describe "legacy algorithms" do 159 | test "allows (request-target) pseudo-headers" do 160 | opts = 161 | PlugSignature.init( 162 | callback_module: SomeModule, 163 | algorithms: ["rsa-sha256"], 164 | headers: "(request-target)" 165 | ) 166 | 167 | assert %{"rsa-sha256" => alg_opts} = Keyword.get(opts, :algorithms) 168 | assert "(request-target)" = alg_opts.headers 169 | assert ["(request-target)"] = alg_opts.header_list 170 | end 171 | 172 | test "disallows other pseudo-headers" do 173 | assert_raise PlugSignature.ConfigError, "invalid header '(created)'", fn -> 174 | PlugSignature.init( 175 | callback_module: SomeModule, 176 | algorithms: ["rsa-sha256"], 177 | headers: "(created)" 178 | ) 179 | end 180 | end 181 | 182 | test "allows any legal HTTP headers" do 183 | opts = 184 | PlugSignature.init( 185 | callback_module: SomeModule, 186 | algorithms: ["rsa-sha256"], 187 | headers: "X-Some-Header MD5-digest weird!" 188 | ) 189 | 190 | assert %{"rsa-sha256" => alg_opts} = Keyword.get(opts, :algorithms) 191 | assert "X-Some-Header MD5-digest weird!" = alg_opts.headers 192 | assert ["x-some-header", "md5-digest", "weird!"] = alg_opts.header_list 193 | end 194 | 195 | test "disallows illegal HTTP headers" do 196 | assert_raise PlugSignature.ConfigError, "invalid header 'illegal:header'", fn -> 197 | PlugSignature.init( 198 | callback_module: SomeModule, 199 | algorithms: ["rsa-sha256"], 200 | headers: "date illegal:header" 201 | ) 202 | end 203 | end 204 | 205 | test "disallows infinite validity " do 206 | assert_raise PlugSignature.ConfigError, 207 | "cannot use infinite validity with legacy algorithms", 208 | fn -> 209 | PlugSignature.init( 210 | callback_module: SomeModule, 211 | algorithms: ["rsa-sha256"], 212 | headers: "(request-target) date", 213 | validity: :infinity 214 | ) 215 | end 216 | end 217 | 218 | test "checks validity type" do 219 | assert_raise PlugSignature.ConfigError, 220 | "validity must be a range", 221 | fn -> 222 | PlugSignature.init( 223 | callback_module: SomeModule, 224 | algorithms: ["rsa-sha256"], 225 | headers: "(request-target) date", 226 | validity: 300 227 | ) 228 | end 229 | end 230 | end 231 | 232 | describe "hs2019 mixed with legacy algorithms" do 233 | test "with default headers and validity" do 234 | opts = 235 | PlugSignature.init( 236 | callback_module: SomeModule, 237 | algorithms: ["hs2019", "rsa-sha256"] 238 | ) 239 | 240 | assert %{"hs2019" => hs2019_opts, "rsa-sha256" => legacy_opts} = 241 | Keyword.get(opts, :algorithms) 242 | 243 | assert "(created)" = hs2019_opts.headers 244 | assert -300..30 == hs2019_opts.validity 245 | assert false == hs2019_opts.check_date_header 246 | 247 | assert "date" = legacy_opts.headers 248 | assert -300..30 == legacy_opts.validity 249 | assert true == legacy_opts.check_date_header 250 | end 251 | 252 | test "fails with incompatible headers" do 253 | assert_raise PlugSignature.ConfigError, 254 | "invalid header '(created)'", 255 | fn -> 256 | PlugSignature.init( 257 | callback_module: SomeModule, 258 | algorithms: ["hs2019", "rsa-sha256"], 259 | headers: "(request-target) (created) host" 260 | ) 261 | end 262 | end 263 | 264 | test "with custom legacy options" do 265 | opts = 266 | PlugSignature.init( 267 | callback_module: SomeModule, 268 | algorithms: ["hs2019", "rsa-sha256"], 269 | headers: "(request-target) (expires) host", 270 | validity: :infinity, 271 | legacy: [ 272 | headers: "(request-target) date host", 273 | validity: -300..30 274 | ] 275 | ) 276 | 277 | assert %{"hs2019" => hs2019_opts, "rsa-sha256" => legacy_opts} = 278 | Keyword.get(opts, :algorithms) 279 | 280 | assert "(request-target) (expires) host" = hs2019_opts.headers 281 | assert :infinity = hs2019_opts.validity 282 | assert false == hs2019_opts.check_date_header 283 | 284 | assert "(request-target) date host" = legacy_opts.headers 285 | assert -300..30 == legacy_opts.validity 286 | assert true == legacy_opts.check_date_header 287 | end 288 | end 289 | end 290 | -------------------------------------------------------------------------------- /test/plug_signature/signature_string_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature.SignatureStringTest do 2 | use ExUnit.Case 3 | doctest PlugSignature.SignatureString 4 | 5 | # Test cases derived from IETF compliance test 6 | # https://github.com/w3c-dvcg/http-signatures-test-suite 7 | 8 | # For valid options MUST return a valid signature string 9 | test "valid" do 10 | assert {:ok, string} = 11 | build( 12 | "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nDate: Tue, 07 Jan 2020 12:16:59 GMT\n\n{\"hello\": \"world\"}\n", 13 | headers: "date" 14 | ) 15 | 16 | assert string == "date: Tue, 07 Jan 2020 12:16:59 GMT" 17 | end 18 | 19 | # If a value is not the last value then append an ASCII newline 20 | test "multi-header" do 21 | assert {:ok, string} = 22 | build( 23 | "POST /foo?param=value&pet=dog HTTP/1.1\nHost: example.com\nContent-Type: application/json\nDigest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\nContent-Length: 18\nDate: Tue, 07 Jan 2020 12:17:00 GMT\n\n{\"hello\": \"world\"}\n", 24 | headers: "digest host" 25 | ) 26 | 27 | assert string == 28 | "digest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\nhost: example.com" 29 | end 30 | 31 | # SHOULD accept lowercase and uppercase HTTP header fields 32 | test "mixed case" do 33 | assert {:ok, string} = 34 | build( 35 | "POST /foo?param=value&pet=dog HTTP/1.1\nhoSt: example.com\ncontent-Type: application/json\nDIgest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\nContent-LenGth: 18\nDate: Tue, 07 Jan 2020 12:17:00 GMT\n\n{\"hello\": \"world\"}\n", 36 | headers: "content-length host digest" 37 | ) 38 | 39 | assert string == 40 | "content-length: 18\nhost: example.com\ndigest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" 41 | end 42 | 43 | # SHOULD be a lowercased, quoted list of HTTP header fields, separated by a 44 | # single space character" 45 | test "headers parameter" do 46 | assert {:ok, string} = 47 | build( 48 | "POST /foo?param=value&pet=dog HTTP/1.1\nHost: example.com\nContent-Type: application/json\nDigest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\nContent-Length: 18\nDate: Tue, 07 Jan 2020 12:17:00 GMT\n\n{\"hello\": \"world\"}\n", 49 | headers: "content-length host digest" 50 | ) 51 | 52 | assert string == 53 | "content-length: 18\nhost: example.com\ndigest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=" 54 | end 55 | 56 | # The client MUST use the values of each HTTP header field in the headers 57 | # Signature Parameter, in the order they appear in the headers Signature 58 | # Parameter 59 | test "order" do 60 | assert {:ok, string} = 61 | build( 62 | "POST /foo?param=value&pet=dog HTTP/1.1\nHost: example.com\nContent-Type: application/json\nDigest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\nContent-Length: 18\nDate: Tue, 07 Jan 2020 12:17:01 GMT\n\n{\"hello\": \"world\"}\n", 63 | headers: "content-length host" 64 | ) 65 | 66 | assert string == 67 | "content-length: 18\nhost: example.com" 68 | end 69 | 70 | # All header field values associated with the header field MUST be 71 | # concatenated, separated by an ASCII comma and an ASCII space, and used in 72 | # the order in which they will appear in the transmitted HTTP message 73 | test "duplicate" do 74 | assert {:ok, string} = 75 | build( 76 | "GET /duplicate/headers HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nHost: example.com\nDuplicate: one, two\nAuthorization: Signature algorithm=\"hmac\"\nfirst: first\nlast: last\nDate: Tue, 07 Jan 2020 12:17:01 GMT\n\n{\"hello\": \"world\"}\n", 77 | headers: "host duplicate" 78 | ) 79 | 80 | assert string == 81 | "host: example.com\nduplicate: one, two" 82 | end 83 | 84 | # If a header specified in the headers parameter cannot be matched with a 85 | # provided header in the message, the implementation MUST produce an error 86 | test "missing" do 87 | assert {:error, "could not build signature_string"} = 88 | build( 89 | "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nDate: Tue, 07 Jan 2020 12:17:01 GMT\n\n{\"hello\": \"world\"}\n", 90 | headers: "not-in-request" 91 | ) 92 | end 93 | 94 | # If a header specified in the headers parameter is malformed the 95 | # implementation MUST produce an error 96 | test "malformed" do 97 | assert {:error, "could not build signature_string"} = 98 | build( 99 | "POST /foo?param=value&pet=dog HTTP/1.1\nHost: example.com\nContent-Type: application/json\nDigest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\nContent-Length: 18\nDate: Tue, 07 Jan 2020 12:17:02 GMT\n\n{\"hello\": \"world\"}\n", 100 | headers: "digest==" 101 | ) 102 | end 103 | 104 | # If the header value is a zero-length string, the signature string line 105 | # correlating with that header will simply be the (lowercased) header name, 106 | # an ASCII colon :, and an ASCII space 107 | test "empty value" do 108 | assert {:ok, string} = 109 | build( 110 | "POST /foo?param=value&pet=dog HTTP/1.1\nHost: example.com\nZero: \nContent-Type: application/json\nDigest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\nContent-Length: 18\nDate: Tue, 07 Jan 2020 12:17:02 GMT\n\n{\"hello\": \"world\"}\n", 111 | headers: "zero" 112 | ) 113 | 114 | assert string == 115 | "zero: " 116 | end 117 | 118 | # SHOULD change capitalized Headers to lowercase 119 | test "lower case result" do 120 | assert {:ok, string} = 121 | build( 122 | "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nDate: Tue, 07 Jan 2020 12:17:02 GMT\n\n{\"hello\": \"world\"}\n", 123 | headers: "connection" 124 | ) 125 | 126 | assert string == 127 | "connection: keep-alive" 128 | end 129 | 130 | # If the header field name is '(request-target)' then generate the header 131 | # field value by concatenating the lowercased :method, an ASCII space, and 132 | # the :path pseudo-header 133 | test "(request-target)" do 134 | assert {:ok, string} = 135 | build( 136 | "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nDate: Tue, 07 Jan 2020 12:17:02 GMT\n\n{\"hello\": \"world\"}\n", 137 | headers: "(request-target)" 138 | ) 139 | 140 | assert string == 141 | "(request-target): get /basic/request" 142 | end 143 | 144 | # SHOULD return \"\" if the headers paramter is empty 145 | test "empty" do 146 | assert {:ok, string} = 147 | build( 148 | "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nDate: Tue, 07 Jan 2020 12:17:03 GMT\n\n{\"hello\": \"world\"}\n", 149 | headers: "" 150 | ) 151 | 152 | assert string == 153 | "" 154 | end 155 | 156 | # If the header parameter is not specified, implementations MUST operate as 157 | # if the field were specified with a single value, '(created)', in the list 158 | # of HTTP headers 159 | test "default" do 160 | assert {:ok, string} = 161 | build( 162 | "POST /foo?param=value&pet=dog HTTP/1.1\nHost: example.com\nContent-Type: application/json\nDigest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\nContent-Length: 18\nAuthorization: Signature keyId=\"foo\",created=1,headers=\"(created)\",signature=\"test\"\nDate: Tue, 07 Jan 2020 12:17:03 GMT\n\n{\"hello\": \"world\"}\n", 163 | created: "1578399423" 164 | ) 165 | 166 | assert string == 167 | "(created): 1578399423" 168 | end 169 | 170 | # name: "If (created) is in headers & the algorithm param starts with rsa MUST produce an error" 171 | # mode: "canonicalize" 172 | # opts: [headers: "(rsa)"] 173 | # input: "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nAuthorization: Signature keyId=\"foo\",algorithm=\"rsa\",created=1,headers=\"(created)\",signature=\"test\"\nDate: Tue, 07 Jan 2020 12:17:03 GMT\n\n{\"hello\": \"world\"}\n" 174 | # result: false 175 | 176 | # name: "If (created) is in headers & the algorithm param starts with hmac MUST produce an error" 177 | # mode: "canonicalize" 178 | # opts: [headers: "(hmac)"] 179 | # input: "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nAuthorization: Signature keyId=\"foo\",algorithm=\"hmac\",headers=\"(created)\",signature=\"test\",created=1\nDate: Tue, 07 Jan 2020 12:17:04 GMT\n\n{\"hello\": \"world\"}\n" 180 | # result: false 181 | 182 | # name: "If (created) is in headers & the algorithm param starts with ecdsa MUST produce an error" 183 | # mode: "canonicalize" 184 | # opts: [headers: "(ecdsa)"] 185 | # input: "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nAuthorization: Signature keyId=\"foo\",algorithm=\"ecdsa\",created=1,headers=\"(created)\",signature=\"test\"\nDate: Tue, 07 Jan 2020 12:17:04 GMT\n\n{\"hello\": \"world\"}\n" 186 | # result: false 187 | 188 | # name: "If the 'created' Signature Parameter is not specified, an implementation MUST produce an error" 189 | # mode: "canonicalize" 190 | # opts: [headers: "(created)"] 191 | # input: "Date: Tue, 07 Jan 2020 12:17:04 GMT\n\n{\"hello\": \"world\"}\n" 192 | # result: false 193 | 194 | # name: "If the created Signature Parameter is not an integer or unix timestamp, an implementation MUST produce an error 195 | # mode: "canonicalize" 196 | # opts: [headers: "(created)", created: "not-an-integer"] 197 | # input: "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nDate: Tue, 07 Jan 2020 12:17:05 GMT\n\n{\"hello\": \"world\"}\n" 198 | # result: false 199 | 200 | # name: "If given valid options SHOULD return '(created)'" 201 | # mode: "canonicalize" 202 | # opts: [headers: "(created)", created: "1578399415"] 203 | # input: "POST /foo?param=value&pet=dog HTTP/1.1\nHost: example.com\nContent-Type: application/json\nDigest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\nContent-Length: 18\nAuthorization: Signature keyId=\"foo\",created=1,headers=\"(created)\",signature=\"test\"\nDate: Tue, 07 Jan 2020 12:17:05 GMT\n\n{\"hello\": \"world\"}\n" 204 | # result: "(created): 1578399415" 205 | 206 | # name: "If (expires) is in headers & the algorithm param starts with rsa MUST produce an error" 207 | # mode: "canonicalize" 208 | # opts: [headers: "(rsa)"] 209 | # input: "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nAuthorization: Signature keyId=\"foo\",algorithm=\"rsa\",created=1,headers=\"(created)\",signature=\"test\"\nDate: Tue, 07 Jan 2020 12:17:05 GMT\n\n{\"hello\": \"world\"}\n" 210 | # result: false 211 | 212 | # name: "If (expires) is in headers & the algorithm param starts with hmac MUST produce an error" 213 | # mode: "canonicalize" 214 | # opts: [headers: "(hmac)"] 215 | # input: "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nAuthorization: Signature keyId=\"foo\",algorithm=\"hmac\",headers=\"(created)\",signature=\"test\",created=1\nDate: Tue, 07 Jan 2020 12:17:05 GMT\n\n{\"hello\": \"world\"}\n" 216 | # result: false 217 | 218 | # name: "If (expires) is in headers & the algorithm param starts with ecdsa MUST produce an error" 219 | # mode: "canonicalize" 220 | # opts: [headers: "(ecdsa)"] 221 | # input: "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nAuthorization: Signature keyId=\"foo\",algorithm=\"ecdsa\",created=1,headers=\"(created)\",signature=\"test\"\nDate: Tue, 07 Jan 2020 12:17:06 GMT\n\n{\"hello\": \"world\"}\n" 222 | # result: false 223 | 224 | # name: "If the 'expires' Signature Parameter is not specified, an implementation MUST produce an error" 225 | # mode: "canonicalize" 226 | # opts: [headers: "(expires)"] 227 | # input: "Date: Tue, 07 Jan 2020 12:17:06 GMT\n\n{\"hello\": \"world\"}\n" 228 | # result: false 229 | 230 | # name: "If the expires Signature Parameter is not an integer or unix timestamp, an implementation MUST produce an error" 231 | # mode: "canonicalize" 232 | # opts: [headers: "(expires)", expires: "not-an-integer"] 233 | # input: "GET /basic/request HTTP/1.1\nConnection: keep-alive\nUser-Agent: Mozilla/5.0 (Macintosh)\nDate: Tue, 07 Jan 2020 12:17:06 GMT\n\n{\"hello\": \"world\"}\n" 234 | # result: false 235 | 236 | # name: "If given valid options SHOULD return '(expires)'" 237 | # mode: "canonicalize" 238 | # opts: [headers: "(expires)", expires: "1578400027"] 239 | # input: "POST /foo?param=value&pet=dog HTTP/1.1\nHost: example.com\nContent-Type: application/json\nDigest: SHA-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE=\nContent-Length: 18\nAuthorization: Signature keyId=\"foo\",expires=0,headers=\"(created)\",signature=\"test\"\nDate: Tue, 07 Jan 2020 12:17:07 GMT\n\n{\"hello\": \"world\"}\n" 240 | # result: "(expires): 1578400027" 241 | 242 | defp build(input, opts) do 243 | header_list = opts |> Keyword.get(:headers, "(created)") |> String.split(" ", trim: true) 244 | PlugSignature.SignatureString.build(conn(input), opts, "hs2019", header_list) 245 | end 246 | 247 | defp conn(input) do 248 | http = String.replace(input, "\n", "\r\n") 249 | {method, path_and_query, _version, more} = :cow_http.parse_request_line(http) 250 | {headers, _body} = :cow_http.parse_headers(more) 251 | 252 | {request_path, query_string} = 253 | case String.split(path_and_query) do 254 | [path] -> {path, ""} 255 | [path, query] -> {path, query} 256 | end 257 | 258 | host = 259 | case List.keyfind(headers, "host", 0) do 260 | {"host", host} -> host 261 | nil -> nil 262 | end 263 | 264 | %Plug.Conn{ 265 | host: host || "www.example.com", 266 | method: method, 267 | request_path: request_path, 268 | query_string: query_string, 269 | req_headers: headers 270 | } 271 | end 272 | end 273 | -------------------------------------------------------------------------------- /lib/plug_signature.ex: -------------------------------------------------------------------------------- 1 | defmodule PlugSignature do 2 | @moduledoc """ 3 | Server side implementation of IETF HTTP signature draft 4 | (https://tools.ietf.org/html/draft-cavage-http-signatures-11), as a reusable 5 | Plug. 6 | 7 | Supports the following algorithms: 8 | 9 | * "hs2019", using ECDSA, RSASSA-PSS or HMAC (all with SHA-512) 10 | * "rsa-sha256", using RSASSA-PKCS1-v1_5 11 | * "ecdsa-sha256" 12 | * "hmac-sha256" 13 | * "rsa-sha1", using RSASSA-PKCS1-v1_5 14 | 15 | ECDSA signatures with hs2019/ecdsa-sha256 are accepted in ASN.1 or raw 16 | r/s format, for maximum interoperability. 17 | 18 | ## Signature validity time window 19 | 20 | Requests signed according to the "hs2019" algorithm should include a 21 | 'created' and/or 'expires' timestamp. When present in the Authorization 22 | header, the 'created' parameter is checked against the configured validity 23 | (unless it is set to `:infinity`) and the 'expires' parameter is checked 24 | against the current time. 25 | 26 | At least one of these parameters should be included in the signature 27 | calculation, through the respecive pseudo-header. The validity check based 28 | on 'created' and/or 'expires' takes place regardless of whether the 29 | parameters were signed. 30 | 31 | For legacy algorithms, signature validity should be checked using the HTTP 32 | 'Date' header. This happens for all algorithms (including "hs2019") if and 33 | only if when the header value is included in the signature. 34 | 35 | ## Signing the request body 36 | 37 | The HTTP 'Digest' header can be used to protect the integrity of the request 38 | body and include it in the signature. The `PlugSignature` Plug treats the 39 | 'Digest' header as any other header, i.e. it verifies the integrity of the 40 | header value if included in the signature, but it does not verify the 41 | integrity of the request body itself. 42 | 43 | Use a Plug such as [PlugBodyDigest](https://hex.pm/packages/plug_body_digest) 44 | to handle the processing of the 'Digest' header. 45 | 46 | ## Options 47 | 48 | * `:callback_module` (mandatory) - the name of a callback module implementing 49 | the `PlugSignature.Callback` behaviour; this module must implement the 50 | `c:PlugSignature.Callback.client_lookup/3` callback 51 | * `:header_name` - name of the request HTTP header from which to take the 52 | signature; when set to "authorization" (the default) all Authorization 53 | header values are scanned for the first entry with a scheme of "Signature"; 54 | for any other value, the contents of the first HTTP header with that name 55 | is used 56 | * `:algorithms` - the signature algorithms, as defined in the IETF 57 | specification; a list containing one or more of: 58 | 59 | * `"hs2019"` (default) 60 | 61 | Legacy algorithms: 62 | 63 | * `"rsa-sha256"` 64 | * `"rsa-sha1"` 65 | * `"ecdsa-sha256"` 66 | * `"hmac-sha256"` 67 | 68 | The first algorithm in the list is considered the default algorithm: if a 69 | client does not specify an algorithm the request is assumed to be signed 70 | using this algorithm 71 | * `:headers` - the minimum set of (pseudo-)headers that need to be signed; 72 | defaults to the request timestamp, taken from the 'created' signature 73 | parameter or the HTTP 'Date' header, depending on the selected algorithm 74 | * `:validity` - a `Range` defining the timeframe (in seconds) after 75 | signing during which the signature is considered valid; set to 76 | `:infinity` to disable, relying solely on the `:expires` parameter; 77 | defaults to `-300..30`, meaning a signature can be up to 5 minutes old, or 78 | up to 30 seconds in the future 79 | * `:legacy` - a keyword list, used to override the `:headers` and/or 80 | `:validity` options for legacy algorithms (those other than "hs2019"); 81 | this may be necessary when these options are set to values not supported 82 | by the legacy algorithms; see the examples below 83 | * `:on_success` - an optional callback for updating the `Plug.Conn` state 84 | upon success; possible values include: 85 | 86 | * `nil` (the default) - do nothing 87 | * `{PlugSignature, :assign_client, [key]}` - assign the client, as 88 | returned by the `c:PlugSignature.Callback.client_lookup/3` callback, 89 | in the `Plug.Conn` struct to the specified key 90 | * `{m, f, a}` - call the function identified by the atom `f` in module 91 | `m`; the function receives the current `Plug.Conn` struct and the 92 | `client` returned by the `c:PlugSignature.Callbacks.client_lookup/3` 93 | callback, along with any additional parameters in the list `a`, and is 94 | expected to return the updated `Plug.Conn` struct 95 | 96 | * `:on_failure` - an optional callback for updating the `Plug.Conn` state 97 | upon failure; possible values include: 98 | 99 | * `{PlugSignature, :failure, []}` (the default) - halt the connection 100 | with an appropriate response; see `failure/3` below 101 | * `{m, f, a}` - call the function identified by the atom `f` in module 102 | `m`; the function receives the current `Plug.Conn` struct, the error 103 | reason,the selected algorithm (a string) and a list of required headers 104 | (strings, for possible use in a 'WWW-Authenticate' response header), 105 | along with any additional parameters in the list `a`; it is expected to 106 | return the updated `Plug.Conn` struct; see the implementation of 107 | `failure/4` for an example 108 | * `nil` - do nothing 109 | 110 | ## Examples 111 | 112 | # Minimal example relying on defaults: "hs2019" algorithm only, with 113 | # minimal "(created)" headers and default validity: 114 | plug PlugSignature, callback_module: MyApp.SignatureAuth 115 | 116 | # More realistic example with custom header configuration; "hs2019" 117 | # only: 118 | plug PlugSignature, 119 | callback_module: MyApp.SignatureAuth, 120 | headers: "(request-target) (created) host digest" 121 | 122 | # Using legacy algorithms only, with custom header set: 123 | plug PlugSignature, 124 | callback_module: MyApp.SignatureAuth, 125 | algorithms: ["ecdsa-sha256", "rsa-sha256"], 126 | headers: "(request-target) date host" 127 | 128 | # Mix of "hs2019" and legacy algorithms, using 'expires' rather than 129 | # 'created' to verify validity for "hs2019"; the `:legacy` option is 130 | # necessary since the `:headers` and `:validity` values are not valid 131 | # for legacy algoritms: 132 | plug PlugSignature, 133 | callback_module: MyApp.SignatureAuth, 134 | headers: "(request-target) (expires) host digest", 135 | validity: :infinity, 136 | legacy: [ 137 | headers: "(request-target) date host digest", 138 | validity: -300..30, 139 | ] 140 | 141 | """ 142 | 143 | import Plug.Conn 144 | require Logger 145 | alias PlugSignature.Config 146 | alias PlugSignature.SignatureString 147 | alias PlugSignature.Crypto 148 | 149 | @behaviour Plug 150 | 151 | @impl true 152 | @spec init(Keyword.t()) :: Keyword.t() 153 | def init(opts) do 154 | Config.new(opts) 155 | end 156 | 157 | @impl true 158 | @spec call(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t() 159 | def call(conn, opts) do 160 | algorithms = Keyword.fetch!(opts, :algorithms) 161 | default_algorithm = Keyword.fetch!(opts, :default_algorithm) 162 | header_name = Keyword.fetch!(opts, :header_name) 163 | 164 | # Parse Authorization header and select algorithm 165 | with {:ok, signature_string} <- get_signature(conn, header_name), 166 | {:ok, signature_opts} <- PlugSignature.Parser.signature(signature_string), 167 | {:ok, algorithm, algorithm_opts} <- 168 | select_algorithm(signature_opts, default_algorithm, algorithms) do 169 | # Ready to call main verification function 170 | verify(conn, algorithm, algorithm_opts, signature_opts, opts) 171 | else 172 | {:error, reason} -> 173 | headers = algorithms[default_algorithm].headers 174 | on_failure(opts[:on_failure], conn, reason, default_algorithm, headers) 175 | end 176 | end 177 | 178 | defp get_signature(conn, "authorization") do 179 | case get_req_header(conn, "authorization") do 180 | [] -> 181 | {:error, "no authorization header"} 182 | 183 | authorizations -> 184 | case Enum.find_value(authorizations, &authorization_signature/1) do 185 | nil -> 186 | {:error, "no signature in authorization header"} 187 | 188 | authorization -> 189 | {:ok, String.trim(authorization)} 190 | end 191 | end 192 | end 193 | 194 | defp get_signature(conn, header_name) do 195 | case get_req_header(conn, header_name) do 196 | [signature_string | _] -> {:ok, String.trim(signature_string)} 197 | _otherwise -> {:error, "no #{header_name} header"} 198 | end 199 | end 200 | 201 | defp authorization_signature(authorization) do 202 | case String.split(authorization, " ", parts: 2) do 203 | [scheme, value] -> 204 | if String.downcase(scheme) == "signature" do 205 | value 206 | else 207 | nil 208 | end 209 | 210 | _ -> 211 | nil 212 | end 213 | end 214 | 215 | defp select_algorithm(signature_opts, default_algorithm, algorithms) do 216 | algorithm = Keyword.get(signature_opts, :algorithm, default_algorithm) 217 | 218 | case algorithms[algorithm] do 219 | nil -> 220 | {:error, "bad signing algorithm #{algorithm}"} 221 | 222 | algorithm_opts -> 223 | {:ok, algorithm, algorithm_opts} 224 | end 225 | end 226 | 227 | defp verify(conn, algorithm, algorithm_opts, signature_opts, opts) do 228 | with {:ok, header_list, signature} <- handle_signature_opts(signature_opts, algorithm_opts), 229 | # Check validity based on timestamp and/or date header 230 | :ok <- verify_signature_timestamp(signature_opts, algorithm_opts.validity), 231 | :ok <- verify_signature_expiry(signature_opts), 232 | :ok <- 233 | verify_date_header(conn, algorithm_opts.validity, algorithm_opts.check_date_header), 234 | # Look up client and credentials 235 | key_id = Keyword.get(signature_opts, :key_id), 236 | {:ok, client, credentials} <- 237 | opts[:callback_module].client_lookup(key_id, algorithm, conn), 238 | # Build string to sign based on headers and algorithm 239 | {:ok, signature_string} <- 240 | SignatureString.build(conn, signature_opts, algorithm, header_list), 241 | # Verify the signature 242 | {:ok, true} <- Crypto.verify(signature_string, algorithm, signature, credentials) do 243 | # All checks passed: continue 244 | on_success(opts[:on_success], conn, client) 245 | else 246 | {:ok, false} -> 247 | reason = "incorrect signature or HMAC" 248 | on_failure(opts[:on_failure], conn, reason, algorithm, algorithm_opts.headers) 249 | 250 | {:error, reason} -> 251 | on_failure(opts[:on_failure], conn, reason, algorithm, algorithm_opts.headers) 252 | end 253 | end 254 | 255 | defp handle_signature_opts(signature_opts, algorithm_opts) do 256 | # - keyId is mandatory, though we do not need it here 257 | # - signature is mandatory 258 | # - headers is optional, at least the expected headers must be present 259 | with {:ok, _key_id} <- fetch(signature_opts, :key_id), 260 | {:ok, signature_b64} <- fetch(signature_opts, :signature), 261 | {:ok, signature} <- decode64(signature_b64) do 262 | headers = Keyword.get(signature_opts, :headers, algorithm_opts.default_headers) 263 | header_list = headers |> String.downcase() |> String.split(" ", trim: true) 264 | 265 | case algorithm_opts.header_list -- header_list do 266 | [] -> 267 | {:ok, header_list, signature} 268 | 269 | missing_headers -> 270 | {:error, "insufficient signature coverage: #{Enum.join(missing_headers, ", ")}"} 271 | end 272 | end 273 | end 274 | 275 | defp fetch(keyword_list, key) do 276 | case Keyword.fetch(keyword_list, key) do 277 | :error -> {:error, "key #{key} not found"} 278 | success -> success 279 | end 280 | end 281 | 282 | defp decode64(b64) do 283 | case Base.decode64(b64) do 284 | :error -> {:error, "Base64 decoding failed"} 285 | success -> success 286 | end 287 | end 288 | 289 | def verify_signature_timestamp(signature_opts, validity \\ -300..30) do 290 | with {:ok, created} <- Keyword.fetch(signature_opts, :created), 291 | {:parse, {unix_int, ""}} <- {:parse, Integer.parse(created)} do 292 | unix_int 293 | |> DateTime.from_unix!() 294 | |> verify_validity(validity) 295 | else 296 | :error -> 297 | # Missing 'created' parameter, cannot verify validity 298 | :ok 299 | 300 | _error -> 301 | {:error, "malformed signature creation timestamp"} 302 | end 303 | end 304 | 305 | def verify_signature_expiry(signature_opts) do 306 | with {:ok, expires_str} <- Keyword.fetch(signature_opts, :expires), 307 | {:parse, {expires, ""}} <- {:parse, Integer.parse(expires_str)} do 308 | now = DateTime.utc_now() |> DateTime.to_unix() 309 | 310 | if now > expires do 311 | {:error, "request expired"} 312 | else 313 | :ok 314 | end 315 | else 316 | :error -> 317 | # Missing 'expires' parameter, cannot verify validity 318 | :ok 319 | 320 | _error -> 321 | {:error, "malformed signature expiry timestamp"} 322 | end 323 | end 324 | 325 | defp verify_date_header(_conn, _validity, false), do: :ok 326 | defp verify_date_header(_conn, :infinity, true), do: :ok 327 | 328 | defp verify_date_header(conn, validity, true) do 329 | case get_req_header(conn, "date") do 330 | [date] -> 331 | date 332 | |> :plug_signature_http_date.parse_date() 333 | |> NaiveDateTime.from_erl!() 334 | |> DateTime.from_naive!("Etc/UTC") 335 | |> verify_validity(validity) 336 | 337 | _otherwise -> 338 | {:error, "missing Date header"} 339 | end 340 | end 341 | 342 | defp verify_validity(_date_time, :infinity), do: :ok 343 | 344 | defp verify_validity(date_time, past..future) do 345 | age = DateTime.diff(date_time, DateTime.utc_now()) 346 | 347 | cond do 348 | age < past -> {:error, "request expired"} 349 | age > future -> {:error, "request timestamp in the future"} 350 | true -> :ok 351 | end 352 | end 353 | 354 | defp on_success(nil, conn, _client), do: conn 355 | defp on_success({m, f, a}, conn, client), do: apply(m, f, [conn, client | a]) 356 | defp on_success(fun, conn, client) when is_function(fun, 2), do: fun.(conn, client) 357 | defp on_success(fun, conn, _client) when is_function(fun, 1), do: fun.(conn) 358 | 359 | defp on_failure(nil, conn, _reason, _algorithm, _headers), do: conn 360 | 361 | defp on_failure({m, f, a}, conn, reason, algorithm, headers), 362 | do: apply(m, f, [conn, reason, algorithm, headers | a]) 363 | 364 | defp on_failure(fun, conn, reason, algorithm, headers) when is_function(fun, 4), 365 | do: fun.(conn, reason, algorithm, headers) 366 | 367 | defp on_failure(fun, conn, reason, algorithm, _headers) when is_function(fun, 3), 368 | do: fun.(conn, reason, algorithm) 369 | 370 | defp on_failure(fun, conn, reason, _algorithm, _headers) when is_function(fun, 2), 371 | do: fun.(conn, reason) 372 | 373 | @doc """ 374 | Success function that assigns the authenticated client under the specified 375 | key in the `Plug.Conn` struct. 376 | """ 377 | @spec assign_client(Plug.Conn.t(), any(), atom()) :: Plug.Conn.t() 378 | def assign_client(conn, client, field_name) do 379 | assign(conn, field_name, client) 380 | end 381 | 382 | @doc """ 383 | The default failure function. 384 | 385 | It logs the failure reason, returns a 401 'Unauthorized' response with a 386 | 'WWW-Authenticate' response header listing the supported algorithms, and 387 | halts the connection. 388 | """ 389 | @spec failure(Plug.Conn.t(), String.t(), String.t(), String.t()) :: Plug.Conn.t() 390 | def failure(conn, reason, algorithm, headers) do 391 | Logger.info("Request unauthorized: #{reason}") 392 | 393 | # We do not expose the exact reason to the client, just a generic 401, to 394 | # avoid leaking information that may help an attacker 395 | conn 396 | |> put_resp_header( 397 | "www-authenticate", 398 | ~s(Signature algorithm=#{algorithm},headers="#{headers}") 399 | ) 400 | |> send_resp(401, "") 401 | |> halt() 402 | end 403 | end 404 | -------------------------------------------------------------------------------- /test/plug_signature_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PlugSignatureTest do 2 | use ExUnit.Case 3 | doctest PlugSignature 4 | 5 | import Plug.Conn 6 | import PlugSignature.ConnTest 7 | import ExUnit.CaptureLog 8 | 9 | defmodule Sample do 10 | # Sample callback module for use in tests 11 | @behaviour PlugSignature.Callback 12 | 13 | @private_ec X509.PrivateKey.new_ec(:secp256r1) 14 | @public_ec X509.PublicKey.derive(@private_ec) 15 | @private_rsa X509.PrivateKey.new_rsa(2048) 16 | @public_rsa X509.PublicKey.derive(@private_rsa) 17 | @secret "supersecret" 18 | 19 | @impl true 20 | def client_lookup("ecdsa", "hs2019", _conn), do: {:ok, nil, @public_ec} 21 | def client_lookup("rsa", "hs2019", _conn), do: {:ok, nil, @public_rsa} 22 | def client_lookup("hmac", "hs2019", _conn), do: {:ok, nil, @secret} 23 | def client_lookup(_, "ecdsa-" <> _, _conn), do: {:ok, nil, @public_ec} 24 | def client_lookup(_, "rsa-" <> _, _conn), do: {:ok, nil, @public_rsa} 25 | def client_lookup(_, "hmac" <> _, _conn), do: {:ok, nil, @secret} 26 | 27 | def private("ecdsa"), do: @private_ec 28 | def private("rsa"), do: @private_rsa 29 | def private("hmac"), do: @secret 30 | end 31 | 32 | defp setup_hs2019_ecdsa(_context), do: do_setup("hs2019", "ecdsa") 33 | defp setup_hs2019_rsa(_context), do: do_setup("hs2019", "rsa") 34 | defp setup_hs2019_hmac(_context), do: do_setup("hs2019", "hmac") 35 | defp setup_rsa_sha256(_context), do: do_setup("rsa-sha256", "rsa") 36 | defp setup_rsa_sha1(_context), do: do_setup("rsa-sha1", "rsa") 37 | defp setup_ecdsa_sha256(_context), do: do_setup("ecdsa-sha256", "ecdsa") 38 | defp setup_hmac_sha256(_context), do: do_setup("hmac-sha256", "hmac") 39 | 40 | defp do_setup(algorithm, key_type) do 41 | timestamp = 42 | case algorithm do 43 | "hs2019" -> "(created)" 44 | _ -> "date" 45 | end 46 | 47 | [ 48 | algorithm: algorithm, 49 | key_id: key_type, 50 | key: Sample.private(key_type), 51 | config: [ 52 | callback_module: Sample, 53 | algorithms: [algorithm], 54 | headers: "(request-target) #{timestamp} host", 55 | validity: -60..15 56 | ] 57 | ] 58 | end 59 | 60 | describe "hs2019 ECDSA" do 61 | setup [:setup_hs2019_ecdsa] 62 | 63 | test "valid", %{config: config, key: key, key_id: key_id} do 64 | conn = 65 | conn() 66 | |> with_signature(key, key_id, config) 67 | |> PlugSignature.call(PlugSignature.init(config)) 68 | 69 | refute conn.halted 70 | end 71 | 72 | test "valid, in Signature header", %{config: config, key: key, key_id: key_id} do 73 | config = Keyword.put(config, :header_name, "signature") 74 | 75 | conn = 76 | conn() 77 | |> with_signature(key, key_id, config) 78 | |> PlugSignature.call(PlugSignature.init(config)) 79 | 80 | refute conn.halted 81 | end 82 | 83 | test "valid, expires in the future", %{config: config, key: key, key_id: key_id} do 84 | conn = 85 | conn() 86 | |> with_signature(key, key_id, Keyword.put(config, :expires_in, 30)) 87 | |> PlugSignature.call(PlugSignature.init(config)) 88 | 89 | refute conn.halted 90 | end 91 | 92 | test "valid, extra Authorization header", %{config: config, key: key, key_id: key_id} do 93 | extra_header = {"authorization", "Basic ZXhhbXBsZTpzZWNyZXQ="} 94 | 95 | conn = 96 | conn() 97 | |> with_signature(key, key_id, config) 98 | |> Map.update(:req_headers, [extra_header], &[extra_header | &1]) 99 | |> PlugSignature.call(PlugSignature.init(config)) 100 | 101 | refute conn.halted 102 | end 103 | 104 | test "invalid, missing Authorization header", %{config: config} do 105 | scenario = fn -> 106 | conn = 107 | conn() 108 | |> PlugSignature.call(PlugSignature.init(config)) 109 | 110 | assert conn.halted 111 | assert conn.status == 401 112 | end 113 | 114 | assert capture_log(scenario) =~ "no authorization header" 115 | end 116 | 117 | test "invalid, missing Signature header", %{config: config} do 118 | config = Keyword.put(config, :header_name, "signature") 119 | 120 | scenario = fn -> 121 | conn = 122 | conn() 123 | |> PlugSignature.call(PlugSignature.init(config)) 124 | 125 | assert conn.halted 126 | assert conn.status == 401 127 | end 128 | 129 | assert capture_log(scenario) =~ "no signature header" 130 | end 131 | 132 | test "invalid, unexpected Authorization scheme", %{config: config} do 133 | scenario = fn -> 134 | conn = 135 | conn() 136 | |> put_req_header("authorization", "Bearer 123") 137 | |> PlugSignature.call(PlugSignature.init(config)) 138 | 139 | assert conn.halted 140 | assert conn.status == 401 141 | end 142 | 143 | assert capture_log(scenario) =~ "no signature in authorization header" 144 | end 145 | 146 | test "invalid, missing keyId", %{config: config, key: key, key_id: key_id} do 147 | scenario = fn -> 148 | conn = 149 | conn() 150 | |> with_signature(key, key_id, Keyword.put(config, :key_id_override, "")) 151 | |> PlugSignature.call(PlugSignature.init(config)) 152 | 153 | assert conn.halted 154 | assert conn.status == 401 155 | end 156 | 157 | assert capture_log(scenario) =~ "key key_id not found" 158 | end 159 | 160 | test "invalid, missing signature", %{config: config, key: key, key_id: key_id} do 161 | scenario = fn -> 162 | conn = 163 | conn() 164 | |> with_signature(key, key_id, Keyword.put(config, :signature_override, "")) 165 | |> PlugSignature.call(PlugSignature.init(config)) 166 | 167 | assert conn.halted 168 | assert conn.status == 401 169 | end 170 | 171 | assert capture_log(scenario) =~ "key signature not found" 172 | end 173 | 174 | test "invalid, malformed signature", %{config: config, key: key, key_id: key_id} do 175 | scenario = fn -> 176 | conn = 177 | conn() 178 | |> with_signature(key, key_id, Keyword.put(config, :signature, "12345")) 179 | |> PlugSignature.call(PlugSignature.init(config)) 180 | 181 | assert conn.halted 182 | assert conn.status == 401 183 | end 184 | 185 | assert capture_log(scenario) =~ "Base64 decoding failed" 186 | end 187 | 188 | test "invalid, insufficient header coverage", %{config: config, key: key, key_id: key_id} do 189 | headers = "(request-target) (created)" 190 | 191 | scenario = fn -> 192 | conn = 193 | conn() 194 | |> with_signature(key, key_id, Keyword.put(config, :headers, headers)) 195 | |> PlugSignature.call(PlugSignature.init(config)) 196 | 197 | assert conn.halted 198 | assert conn.status == 401 199 | end 200 | 201 | assert capture_log(scenario) =~ "insufficient signature coverage" 202 | end 203 | 204 | test "invalid, mismatched host header", %{config: config, key: key, key_id: key_id} do 205 | scenario = fn -> 206 | conn = 207 | conn() 208 | |> with_signature(key, key_id, config) 209 | |> Map.put(:host, "example.org") 210 | |> PlugSignature.call(PlugSignature.init(config)) 211 | 212 | assert conn.halted 213 | assert conn.status == 401 214 | end 215 | 216 | assert capture_log(scenario) =~ "incorrect signature" 217 | end 218 | 219 | test "invalid, mismatched optional header", %{config: config, key: key, key_id: key_id} do 220 | headers = "(request-target) (created) host user-agent" 221 | 222 | scenario = fn -> 223 | conn = 224 | conn() 225 | |> put_req_header("user-agent", "test") 226 | |> with_signature(key, key_id, Keyword.put(config, :headers, headers)) 227 | |> put_req_header("user-agent", "wrong") 228 | |> PlugSignature.call(PlugSignature.init(config)) 229 | 230 | assert conn.halted 231 | assert conn.status == 401 232 | end 233 | 234 | assert capture_log(scenario) =~ "incorrect signature" 235 | end 236 | 237 | test "invalid, missing 'created' timestamp", %{config: config, key: key, key_id: key_id} do 238 | scenario = fn -> 239 | conn = 240 | conn() 241 | |> with_signature(key, key_id, Keyword.put(config, :created_override, "")) 242 | |> PlugSignature.call(PlugSignature.init(config)) 243 | 244 | assert conn.halted 245 | assert conn.status == 401 246 | end 247 | 248 | assert capture_log(scenario) =~ "could not build signature_string" 249 | end 250 | 251 | test "invalid, malformed 'created' timestamp", %{config: config, key: key, key_id: key_id} do 252 | scenario = fn -> 253 | conn = 254 | conn() 255 | |> with_signature(key, key_id, Keyword.put(config, :created, "10a19")) 256 | |> PlugSignature.call(PlugSignature.init(config)) 257 | 258 | assert conn.halted 259 | assert conn.status == 401 260 | end 261 | 262 | assert capture_log(scenario) =~ "malformed signature creation timestamp" 263 | end 264 | 265 | test "invalid, old 'created' timestamp", %{config: config, key: key, key_id: key_id} do 266 | scenario = fn -> 267 | conn = 268 | conn() 269 | |> with_signature(key, key_id, Keyword.put(config, :age, 120)) 270 | |> PlugSignature.call(PlugSignature.init(config)) 271 | 272 | assert conn.halted 273 | assert conn.status == 401 274 | end 275 | 276 | assert capture_log(scenario) =~ "request expired" 277 | end 278 | 279 | test "invalid, future 'created' timestamp", %{config: config, key: key, key_id: key_id} do 280 | scenario = fn -> 281 | conn = 282 | conn() 283 | |> with_signature(key, key_id, Keyword.put(config, :age, -60)) 284 | |> PlugSignature.call(PlugSignature.init(config)) 285 | 286 | assert conn.halted 287 | assert conn.status == 401 288 | end 289 | 290 | assert capture_log(scenario) =~ "request timestamp in the future" 291 | end 292 | 293 | test "invalid, expired", %{config: config, key: key, key_id: key_id} do 294 | headers = "(request-target) (created) (expires) host digest" 295 | 296 | config = 297 | config 298 | |> Keyword.put(:headers, headers) 299 | |> Keyword.put(:expires_in, -30) 300 | 301 | scenario = fn -> 302 | conn = 303 | conn() 304 | |> with_signature(key, key_id, config) 305 | |> PlugSignature.call(PlugSignature.init(config)) 306 | 307 | assert conn.halted 308 | assert conn.status == 401 309 | end 310 | 311 | assert capture_log(scenario) =~ "request expired" 312 | end 313 | 314 | test "invalid, missing header", %{config: config, key: key, key_id: key_id} do 315 | headers = "(request-target) (created) host digest nosuchheader" 316 | 317 | scenario = fn -> 318 | conn = 319 | conn() 320 | |> with_signature(key, key_id, Keyword.put(config, :headers, headers)) 321 | |> PlugSignature.call(PlugSignature.init(config)) 322 | 323 | assert conn.halted 324 | assert conn.status == 401 325 | end 326 | 327 | assert capture_log(scenario) =~ "could not build signature_string" 328 | end 329 | 330 | test "invalid, missing expires param", %{config: config, key: key, key_id: key_id} do 331 | headers = "(request-target) (created) (expires) host digest" 332 | 333 | scenario = fn -> 334 | conn = 335 | conn() 336 | |> with_signature(key, key_id, Keyword.put(config, :headers, headers)) 337 | |> PlugSignature.call(PlugSignature.init(config)) 338 | 339 | assert conn.halted 340 | assert conn.status == 401 341 | end 342 | 343 | assert capture_log(scenario) =~ "could not build signature_string" 344 | end 345 | end 346 | 347 | describe "hs2019 RSA" do 348 | setup [:setup_hs2019_rsa] 349 | 350 | test "valid", %{config: config, key: key, key_id: key_id} do 351 | conn = 352 | conn() 353 | |> with_signature(key, key_id, config) 354 | |> PlugSignature.call(PlugSignature.init(config)) 355 | 356 | refute conn.halted 357 | end 358 | end 359 | 360 | describe "hs2019 HMAC" do 361 | setup [:setup_hs2019_hmac] 362 | 363 | test "valid", %{config: config, key: key, key_id: key_id} do 364 | conn = 365 | conn() 366 | |> with_signature(key, key_id, config) 367 | |> PlugSignature.call(PlugSignature.init(config)) 368 | 369 | refute conn.halted 370 | end 371 | end 372 | 373 | describe "rsa-sha256" do 374 | setup [:setup_rsa_sha256] 375 | 376 | test "valid", %{config: config, key: key, key_id: key_id} do 377 | conn = 378 | conn() 379 | |> with_signature(key, key_id, config) 380 | |> PlugSignature.call(PlugSignature.init(config)) 381 | 382 | refute conn.halted 383 | end 384 | 385 | test "invalid, wrong algorithm", %{config: config, key: key, key_id: key_id} do 386 | scenario = fn -> 387 | conn = 388 | conn() 389 | |> with_signature(key, key_id, Keyword.put(config, :algorithms, ["hs2019"])) 390 | |> PlugSignature.call(PlugSignature.init(config)) 391 | 392 | assert conn.halted 393 | assert conn.status == 401 394 | end 395 | 396 | assert capture_log(scenario) =~ "bad signing algorithm" 397 | end 398 | 399 | test "invalid, old Date header", %{config: config, key: key, key_id: key_id} do 400 | scenario = fn -> 401 | conn = 402 | conn() 403 | |> with_signature(key, key_id, Keyword.put(config, :age, 120)) 404 | |> PlugSignature.call(PlugSignature.init(config)) 405 | 406 | assert conn.halted 407 | assert conn.status == 401 408 | end 409 | 410 | assert capture_log(scenario) =~ "request expired" 411 | end 412 | 413 | test "invalid, future Date header", %{config: config, key: key, key_id: key_id} do 414 | scenario = fn -> 415 | conn = 416 | conn() 417 | |> with_signature(key, key_id, Keyword.put(config, :age, -60)) 418 | |> PlugSignature.call(PlugSignature.init(config)) 419 | 420 | assert conn.halted 421 | assert conn.status == 401 422 | end 423 | 424 | assert capture_log(scenario) =~ "request timestamp in the future" 425 | end 426 | 427 | test "invalid, (created) pseudo header", %{config: config, key: key, key_id: key_id} do 428 | headers = "(request-target) (created) date host digest" 429 | 430 | scenario = fn -> 431 | conn = 432 | conn() 433 | |> with_signature(key, key_id, Keyword.put(config, :headers, headers)) 434 | |> PlugSignature.call(PlugSignature.init(config)) 435 | 436 | assert conn.halted 437 | assert conn.status == 401 438 | end 439 | 440 | assert capture_log(scenario) =~ "could not build signature_string" 441 | end 442 | 443 | test "invalid, (expires) pseudo header", %{config: config, key: key, key_id: key_id} do 444 | headers = "(request-target) (expires) date host digest" 445 | 446 | scenario = fn -> 447 | conn = 448 | conn() 449 | |> with_signature(key, key_id, Keyword.put(config, :headers, headers)) 450 | |> PlugSignature.call(PlugSignature.init(config)) 451 | 452 | assert conn.halted 453 | assert conn.status == 401 454 | end 455 | 456 | assert capture_log(scenario) =~ "could not build signature_string" 457 | end 458 | end 459 | 460 | describe "rsa-sha" do 461 | setup [:setup_rsa_sha1] 462 | 463 | test "valid", %{config: config, key: key, key_id: key_id} do 464 | conn = 465 | conn() 466 | |> with_signature(key, key_id, config) 467 | |> PlugSignature.call(PlugSignature.init(config)) 468 | 469 | refute conn.halted 470 | end 471 | end 472 | 473 | describe "ecdsa-sha256" do 474 | setup [:setup_ecdsa_sha256] 475 | 476 | test "valid", %{config: config, key: key, key_id: key_id} do 477 | conn = 478 | conn() 479 | |> with_signature(key, key_id, config) 480 | |> PlugSignature.call(PlugSignature.init(config)) 481 | 482 | refute conn.halted 483 | end 484 | end 485 | 486 | describe "hmac-sha256" do 487 | setup [:setup_hmac_sha256] 488 | 489 | test "valid", %{config: config, key: key, key_id: key_id} do 490 | conn = 491 | conn() 492 | |> with_signature(key, key_id, config) 493 | |> PlugSignature.call(PlugSignature.init(config)) 494 | 495 | refute conn.halted 496 | end 497 | end 498 | 499 | defp conn() do 500 | host = "localhost:4000" 501 | 502 | Plug.Test.conn(:get, "http://#{host}/") 503 | end 504 | end 505 | -------------------------------------------------------------------------------- /src/plug_signature_http_date.erl: -------------------------------------------------------------------------------- 1 | %% Vendored from cowlib 2.7.3, due to build issues with cowlib 1.x. 2 | %% 3 | %% Changes: 4 | %% - changed module name from cow_date, to avoid name clash 5 | %% 6 | %% Original copyright follows: 7 | 8 | %% Copyright (c) 2013-2018, Loïc Hoguin 9 | %% 10 | %% Permission to use, copy, modify, and/or distribute this software for any 11 | %% purpose with or without fee is hereby granted, provided that the above 12 | %% copyright notice and this permission notice appear in all copies. 13 | %% 14 | %% THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 15 | %% WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 16 | %% MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 17 | %% ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 18 | %% WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 19 | %% ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 20 | %% OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 21 | 22 | -module(plug_signature_http_date). 23 | 24 | -export([parse_date/1]). 25 | -export([rfc1123/1]). 26 | -export([rfc2109/1]). 27 | -export([rfc7231/1]). 28 | 29 | -ifdef(TEST). 30 | -include_lib("proper/include/proper.hrl"). 31 | -endif. 32 | 33 | %% @doc Parse the HTTP date (IMF-fixdate, rfc850, asctime). 34 | 35 | -define(DIGITS(A, B), ((A - $0) * 10 + (B - $0))). 36 | -define(DIGITS(A, B, C, D), ((A - $0) * 1000 + (B - $0) * 100 + (C - $0) * 10 + (D - $0))). 37 | 38 | -spec parse_date(binary()) -> calendar:datetime(). 39 | parse_date(DateBin) -> 40 | Date = {{_, _, D}, {H, M, S}} = http_date(DateBin), 41 | true = D >= 0 andalso D =< 31, 42 | true = H >= 0 andalso H =< 23, 43 | true = M >= 0 andalso M =< 59, 44 | true = S >= 0 andalso S =< 60, %% Leap second. 45 | Date. 46 | 47 | http_date(<<"Mon, ", D1, D2, " ", R/bits >>) -> fixdate(R, ?DIGITS(D1, D2)); 48 | http_date(<<"Tue, ", D1, D2, " ", R/bits >>) -> fixdate(R, ?DIGITS(D1, D2)); 49 | http_date(<<"Wed, ", D1, D2, " ", R/bits >>) -> fixdate(R, ?DIGITS(D1, D2)); 50 | http_date(<<"Thu, ", D1, D2, " ", R/bits >>) -> fixdate(R, ?DIGITS(D1, D2)); 51 | http_date(<<"Fri, ", D1, D2, " ", R/bits >>) -> fixdate(R, ?DIGITS(D1, D2)); 52 | http_date(<<"Sat, ", D1, D2, " ", R/bits >>) -> fixdate(R, ?DIGITS(D1, D2)); 53 | http_date(<<"Sun, ", D1, D2, " ", R/bits >>) -> fixdate(R, ?DIGITS(D1, D2)); 54 | http_date(<<"Monday, ", D1, D2, "-", R/bits >>) -> rfc850_date(R, ?DIGITS(D1, D2)); 55 | http_date(<<"Tuesday, ", D1, D2, "-", R/bits >>) -> rfc850_date(R, ?DIGITS(D1, D2)); 56 | http_date(<<"Wednesday, ", D1, D2, "-", R/bits >>) -> rfc850_date(R, ?DIGITS(D1, D2)); 57 | http_date(<<"Thursday, ", D1, D2, "-", R/bits >>) -> rfc850_date(R, ?DIGITS(D1, D2)); 58 | http_date(<<"Friday, ", D1, D2, "-", R/bits >>) -> rfc850_date(R, ?DIGITS(D1, D2)); 59 | http_date(<<"Saturday, ", D1, D2, "-", R/bits >>) -> rfc850_date(R, ?DIGITS(D1, D2)); 60 | http_date(<<"Sunday, ", D1, D2, "-", R/bits >>) -> rfc850_date(R, ?DIGITS(D1, D2)); 61 | http_date(<<"Mon ", R/bits >>) -> asctime_date(R); 62 | http_date(<<"Tue ", R/bits >>) -> asctime_date(R); 63 | http_date(<<"Wed ", R/bits >>) -> asctime_date(R); 64 | http_date(<<"Thu ", R/bits >>) -> asctime_date(R); 65 | http_date(<<"Fri ", R/bits >>) -> asctime_date(R); 66 | http_date(<<"Sat ", R/bits >>) -> asctime_date(R); 67 | http_date(<<"Sun ", R/bits >>) -> asctime_date(R). 68 | 69 | fixdate(<<"Jan ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 70 | {{?DIGITS(Y1, Y2, Y3, Y4), 1, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 71 | fixdate(<<"Feb ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 72 | {{?DIGITS(Y1, Y2, Y3, Y4), 2, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 73 | fixdate(<<"Mar ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 74 | {{?DIGITS(Y1, Y2, Y3, Y4), 3, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 75 | fixdate(<<"Apr ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 76 | {{?DIGITS(Y1, Y2, Y3, Y4), 4, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 77 | fixdate(<<"May ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 78 | {{?DIGITS(Y1, Y2, Y3, Y4), 5, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 79 | fixdate(<<"Jun ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 80 | {{?DIGITS(Y1, Y2, Y3, Y4), 6, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 81 | fixdate(<<"Jul ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 82 | {{?DIGITS(Y1, Y2, Y3, Y4), 7, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 83 | fixdate(<<"Aug ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 84 | {{?DIGITS(Y1, Y2, Y3, Y4), 8, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 85 | fixdate(<<"Sep ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 86 | {{?DIGITS(Y1, Y2, Y3, Y4), 9, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 87 | fixdate(<<"Oct ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 88 | {{?DIGITS(Y1, Y2, Y3, Y4), 10, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 89 | fixdate(<<"Nov ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 90 | {{?DIGITS(Y1, Y2, Y3, Y4), 11, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 91 | fixdate(<<"Dec ", Y1, Y2, Y3, Y4, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 92 | {{?DIGITS(Y1, Y2, Y3, Y4), 12, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}. 93 | 94 | rfc850_date(<<"Jan-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 95 | {{rfc850_year(?DIGITS(Y1, Y2)), 1, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 96 | rfc850_date(<<"Feb-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 97 | {{rfc850_year(?DIGITS(Y1, Y2)), 2, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 98 | rfc850_date(<<"Mar-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 99 | {{rfc850_year(?DIGITS(Y1, Y2)), 3, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 100 | rfc850_date(<<"Apr-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 101 | {{rfc850_year(?DIGITS(Y1, Y2)), 4, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 102 | rfc850_date(<<"May-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 103 | {{rfc850_year(?DIGITS(Y1, Y2)), 5, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 104 | rfc850_date(<<"Jun-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 105 | {{rfc850_year(?DIGITS(Y1, Y2)), 6, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 106 | rfc850_date(<<"Jul-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 107 | {{rfc850_year(?DIGITS(Y1, Y2)), 7, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 108 | rfc850_date(<<"Aug-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 109 | {{rfc850_year(?DIGITS(Y1, Y2)), 8, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 110 | rfc850_date(<<"Sep-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 111 | {{rfc850_year(?DIGITS(Y1, Y2)), 9, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 112 | rfc850_date(<<"Oct-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 113 | {{rfc850_year(?DIGITS(Y1, Y2)), 10, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 114 | rfc850_date(<<"Nov-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 115 | {{rfc850_year(?DIGITS(Y1, Y2)), 11, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 116 | rfc850_date(<<"Dec-", Y1, Y2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " GMT">>, Day) -> 117 | {{rfc850_year(?DIGITS(Y1, Y2)), 12, Day}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}. 118 | 119 | rfc850_year(Y) when Y > 50 -> Y + 1900; 120 | rfc850_year(Y) -> Y + 2000. 121 | 122 | asctime_date(<<"Jan ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 123 | {{?DIGITS(Y1, Y2, Y3, Y4), 1, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 124 | asctime_date(<<"Feb ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 125 | {{?DIGITS(Y1, Y2, Y3, Y4), 2, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 126 | asctime_date(<<"Mar ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 127 | {{?DIGITS(Y1, Y2, Y3, Y4), 3, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 128 | asctime_date(<<"Apr ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 129 | {{?DIGITS(Y1, Y2, Y3, Y4), 4, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 130 | asctime_date(<<"May ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 131 | {{?DIGITS(Y1, Y2, Y3, Y4), 5, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 132 | asctime_date(<<"Jun ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 133 | {{?DIGITS(Y1, Y2, Y3, Y4), 6, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 134 | asctime_date(<<"Jul ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 135 | {{?DIGITS(Y1, Y2, Y3, Y4), 7, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 136 | asctime_date(<<"Aug ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 137 | {{?DIGITS(Y1, Y2, Y3, Y4), 8, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 138 | asctime_date(<<"Sep ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 139 | {{?DIGITS(Y1, Y2, Y3, Y4), 9, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 140 | asctime_date(<<"Oct ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 141 | {{?DIGITS(Y1, Y2, Y3, Y4), 10, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 142 | asctime_date(<<"Nov ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 143 | {{?DIGITS(Y1, Y2, Y3, Y4), 11, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}; 144 | asctime_date(<<"Dec ", D1, D2, " ", H1, H2, ":", M1, M2, ":", S1, S2, " ", Y1, Y2, Y3, Y4 >>) -> 145 | {{?DIGITS(Y1, Y2, Y3, Y4), 12, asctime_day(D1, D2)}, {?DIGITS(H1, H2), ?DIGITS(M1, M2), ?DIGITS(S1, S2)}}. 146 | 147 | asctime_day($\s, D2) -> (D2 - $0); 148 | asctime_day(D1, D2) -> (D1 - $0) * 10 + (D2 - $0). 149 | 150 | -ifdef(TEST). 151 | day_name() -> oneof(["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]). 152 | day_name_l() -> oneof(["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]). 153 | year() -> integer(1951, 2050). 154 | month() -> integer(1, 12). 155 | day() -> integer(1, 31). 156 | hour() -> integer(0, 23). 157 | minute() -> integer(0, 59). 158 | second() -> integer(0, 60). 159 | 160 | fixdate_gen() -> 161 | ?LET({DayName, Y, Mo, D, H, Mi, S}, 162 | {day_name(), year(), month(), day(), hour(), minute(), second()}, 163 | {{{Y, Mo, D}, {H, Mi, S}}, 164 | list_to_binary([DayName, ", ", pad_int(D), " ", month(Mo), " ", integer_to_binary(Y), 165 | " ", pad_int(H), ":", pad_int(Mi), ":", pad_int(S), " GMT"])}). 166 | 167 | rfc850_gen() -> 168 | ?LET({DayName, Y, Mo, D, H, Mi, S}, 169 | {day_name_l(), year(), month(), day(), hour(), minute(), second()}, 170 | {{{Y, Mo, D}, {H, Mi, S}}, 171 | list_to_binary([DayName, ", ", pad_int(D), "-", month(Mo), "-", pad_int(Y rem 100), 172 | " ", pad_int(H), ":", pad_int(Mi), ":", pad_int(S), " GMT"])}). 173 | 174 | asctime_gen() -> 175 | ?LET({DayName, Y, Mo, D, H, Mi, S}, 176 | {day_name(), year(), month(), day(), hour(), minute(), second()}, 177 | {{{Y, Mo, D}, {H, Mi, S}}, 178 | list_to_binary([DayName, " ", month(Mo), " ", 179 | if D < 10 -> << $\s, (D + $0) >>; true -> integer_to_binary(D) end, 180 | " ", pad_int(H), ":", pad_int(Mi), ":", pad_int(S), " ", integer_to_binary(Y)])}). 181 | 182 | prop_http_date() -> 183 | ?FORALL({Date, DateBin}, 184 | oneof([fixdate_gen(), rfc850_gen(), asctime_gen()]), 185 | Date =:= parse_date(DateBin)). 186 | 187 | http_date_test_() -> 188 | Tests = [ 189 | {<<"Sun, 06 Nov 1994 08:49:37 GMT">>, {{1994, 11, 6}, {8, 49, 37}}}, 190 | {<<"Sunday, 06-Nov-94 08:49:37 GMT">>, {{1994, 11, 6}, {8, 49, 37}}}, 191 | {<<"Sun Nov 6 08:49:37 1994">>, {{1994, 11, 6}, {8, 49, 37}}} 192 | ], 193 | [{V, fun() -> R = http_date(V) end} || {V, R} <- Tests]. 194 | 195 | horse_http_date_fixdate() -> 196 | horse:repeat(200000, 197 | http_date(<<"Sun, 06 Nov 1994 08:49:37 GMT">>) 198 | ). 199 | 200 | horse_http_date_rfc850() -> 201 | horse:repeat(200000, 202 | http_date(<<"Sunday, 06-Nov-94 08:49:37 GMT">>) 203 | ). 204 | 205 | horse_http_date_asctime() -> 206 | horse:repeat(200000, 207 | http_date(<<"Sun Nov 6 08:49:37 1994">>) 208 | ). 209 | -endif. 210 | 211 | %% @doc Return the date formatted according to RFC1123. 212 | 213 | -spec rfc1123(calendar:datetime()) -> binary(). 214 | rfc1123(DateTime) -> 215 | rfc7231(DateTime). 216 | 217 | %% @doc Return the date formatted according to RFC2109. 218 | 219 | -spec rfc2109(calendar:datetime()) -> binary(). 220 | rfc2109({Date = {Y, Mo, D}, {H, Mi, S}}) -> 221 | Wday = calendar:day_of_the_week(Date), 222 | << (weekday(Wday))/binary, ", ", 223 | (pad_int(D))/binary, "-", 224 | (month(Mo))/binary, "-", 225 | (year(Y))/binary, " ", 226 | (pad_int(H))/binary, ":", 227 | (pad_int(Mi))/binary, ":", 228 | (pad_int(S))/binary, " GMT" >>. 229 | 230 | -ifdef(TEST). 231 | rfc2109_test_() -> 232 | Tests = [ 233 | {<<"Sat, 14-May-2011 14:25:33 GMT">>, {{2011, 5, 14}, {14, 25, 33}}}, 234 | {<<"Sun, 01-Jan-2012 00:00:00 GMT">>, {{2012, 1, 1}, { 0, 0, 0}}} 235 | ], 236 | [{R, fun() -> R = rfc2109(D) end} || {R, D} <- Tests]. 237 | 238 | horse_rfc2109_20130101_000000() -> 239 | horse:repeat(100000, 240 | rfc2109({{2013, 1, 1}, {0, 0, 0}}) 241 | ). 242 | 243 | horse_rfc2109_20131231_235959() -> 244 | horse:repeat(100000, 245 | rfc2109({{2013, 12, 31}, {23, 59, 59}}) 246 | ). 247 | 248 | horse_rfc2109_12340506_070809() -> 249 | horse:repeat(100000, 250 | rfc2109({{1234, 5, 6}, {7, 8, 9}}) 251 | ). 252 | -endif. 253 | 254 | %% @doc Return the date formatted according to RFC7231. 255 | 256 | -spec rfc7231(calendar:datetime()) -> binary(). 257 | rfc7231({Date = {Y, Mo, D}, {H, Mi, S}}) -> 258 | Wday = calendar:day_of_the_week(Date), 259 | << (weekday(Wday))/binary, ", ", 260 | (pad_int(D))/binary, " ", 261 | (month(Mo))/binary, " ", 262 | (year(Y))/binary, " ", 263 | (pad_int(H))/binary, ":", 264 | (pad_int(Mi))/binary, ":", 265 | (pad_int(S))/binary, " GMT" >>. 266 | 267 | -ifdef(TEST). 268 | rfc7231_test_() -> 269 | Tests = [ 270 | {<<"Sat, 14 May 2011 14:25:33 GMT">>, {{2011, 5, 14}, {14, 25, 33}}}, 271 | {<<"Sun, 01 Jan 2012 00:00:00 GMT">>, {{2012, 1, 1}, { 0, 0, 0}}} 272 | ], 273 | [{R, fun() -> R = rfc7231(D) end} || {R, D} <- Tests]. 274 | 275 | horse_rfc7231_20130101_000000() -> 276 | horse:repeat(100000, 277 | rfc7231({{2013, 1, 1}, {0, 0, 0}}) 278 | ). 279 | 280 | horse_rfc7231_20131231_235959() -> 281 | horse:repeat(100000, 282 | rfc7231({{2013, 12, 31}, {23, 59, 59}}) 283 | ). 284 | 285 | horse_rfc7231_12340506_070809() -> 286 | horse:repeat(100000, 287 | rfc7231({{1234, 5, 6}, {7, 8, 9}}) 288 | ). 289 | -endif. 290 | 291 | %% Internal. 292 | 293 | -spec pad_int(0..59) -> <<_:16>>. 294 | pad_int( 0) -> <<"00">>; 295 | pad_int( 1) -> <<"01">>; 296 | pad_int( 2) -> <<"02">>; 297 | pad_int( 3) -> <<"03">>; 298 | pad_int( 4) -> <<"04">>; 299 | pad_int( 5) -> <<"05">>; 300 | pad_int( 6) -> <<"06">>; 301 | pad_int( 7) -> <<"07">>; 302 | pad_int( 8) -> <<"08">>; 303 | pad_int( 9) -> <<"09">>; 304 | pad_int(10) -> <<"10">>; 305 | pad_int(11) -> <<"11">>; 306 | pad_int(12) -> <<"12">>; 307 | pad_int(13) -> <<"13">>; 308 | pad_int(14) -> <<"14">>; 309 | pad_int(15) -> <<"15">>; 310 | pad_int(16) -> <<"16">>; 311 | pad_int(17) -> <<"17">>; 312 | pad_int(18) -> <<"18">>; 313 | pad_int(19) -> <<"19">>; 314 | pad_int(20) -> <<"20">>; 315 | pad_int(21) -> <<"21">>; 316 | pad_int(22) -> <<"22">>; 317 | pad_int(23) -> <<"23">>; 318 | pad_int(24) -> <<"24">>; 319 | pad_int(25) -> <<"25">>; 320 | pad_int(26) -> <<"26">>; 321 | pad_int(27) -> <<"27">>; 322 | pad_int(28) -> <<"28">>; 323 | pad_int(29) -> <<"29">>; 324 | pad_int(30) -> <<"30">>; 325 | pad_int(31) -> <<"31">>; 326 | pad_int(32) -> <<"32">>; 327 | pad_int(33) -> <<"33">>; 328 | pad_int(34) -> <<"34">>; 329 | pad_int(35) -> <<"35">>; 330 | pad_int(36) -> <<"36">>; 331 | pad_int(37) -> <<"37">>; 332 | pad_int(38) -> <<"38">>; 333 | pad_int(39) -> <<"39">>; 334 | pad_int(40) -> <<"40">>; 335 | pad_int(41) -> <<"41">>; 336 | pad_int(42) -> <<"42">>; 337 | pad_int(43) -> <<"43">>; 338 | pad_int(44) -> <<"44">>; 339 | pad_int(45) -> <<"45">>; 340 | pad_int(46) -> <<"46">>; 341 | pad_int(47) -> <<"47">>; 342 | pad_int(48) -> <<"48">>; 343 | pad_int(49) -> <<"49">>; 344 | pad_int(50) -> <<"50">>; 345 | pad_int(51) -> <<"51">>; 346 | pad_int(52) -> <<"52">>; 347 | pad_int(53) -> <<"53">>; 348 | pad_int(54) -> <<"54">>; 349 | pad_int(55) -> <<"55">>; 350 | pad_int(56) -> <<"56">>; 351 | pad_int(57) -> <<"57">>; 352 | pad_int(58) -> <<"58">>; 353 | pad_int(59) -> <<"59">>; 354 | pad_int(60) -> <<"60">>; 355 | pad_int(Int) -> integer_to_binary(Int). 356 | 357 | -spec weekday(1..7) -> <<_:24>>. 358 | weekday(1) -> <<"Mon">>; 359 | weekday(2) -> <<"Tue">>; 360 | weekday(3) -> <<"Wed">>; 361 | weekday(4) -> <<"Thu">>; 362 | weekday(5) -> <<"Fri">>; 363 | weekday(6) -> <<"Sat">>; 364 | weekday(7) -> <<"Sun">>. 365 | 366 | -spec month(1..12) -> <<_:24>>. 367 | month( 1) -> <<"Jan">>; 368 | month( 2) -> <<"Feb">>; 369 | month( 3) -> <<"Mar">>; 370 | month( 4) -> <<"Apr">>; 371 | month( 5) -> <<"May">>; 372 | month( 6) -> <<"Jun">>; 373 | month( 7) -> <<"Jul">>; 374 | month( 8) -> <<"Aug">>; 375 | month( 9) -> <<"Sep">>; 376 | month(10) -> <<"Oct">>; 377 | month(11) -> <<"Nov">>; 378 | month(12) -> <<"Dec">>. 379 | 380 | -spec year(pos_integer()) -> <<_:32>>. 381 | year(1970) -> <<"1970">>; 382 | year(1971) -> <<"1971">>; 383 | year(1972) -> <<"1972">>; 384 | year(1973) -> <<"1973">>; 385 | year(1974) -> <<"1974">>; 386 | year(1975) -> <<"1975">>; 387 | year(1976) -> <<"1976">>; 388 | year(1977) -> <<"1977">>; 389 | year(1978) -> <<"1978">>; 390 | year(1979) -> <<"1979">>; 391 | year(1980) -> <<"1980">>; 392 | year(1981) -> <<"1981">>; 393 | year(1982) -> <<"1982">>; 394 | year(1983) -> <<"1983">>; 395 | year(1984) -> <<"1984">>; 396 | year(1985) -> <<"1985">>; 397 | year(1986) -> <<"1986">>; 398 | year(1987) -> <<"1987">>; 399 | year(1988) -> <<"1988">>; 400 | year(1989) -> <<"1989">>; 401 | year(1990) -> <<"1990">>; 402 | year(1991) -> <<"1991">>; 403 | year(1992) -> <<"1992">>; 404 | year(1993) -> <<"1993">>; 405 | year(1994) -> <<"1994">>; 406 | year(1995) -> <<"1995">>; 407 | year(1996) -> <<"1996">>; 408 | year(1997) -> <<"1997">>; 409 | year(1998) -> <<"1998">>; 410 | year(1999) -> <<"1999">>; 411 | year(2000) -> <<"2000">>; 412 | year(2001) -> <<"2001">>; 413 | year(2002) -> <<"2002">>; 414 | year(2003) -> <<"2003">>; 415 | year(2004) -> <<"2004">>; 416 | year(2005) -> <<"2005">>; 417 | year(2006) -> <<"2006">>; 418 | year(2007) -> <<"2007">>; 419 | year(2008) -> <<"2008">>; 420 | year(2009) -> <<"2009">>; 421 | year(2010) -> <<"2010">>; 422 | year(2011) -> <<"2011">>; 423 | year(2012) -> <<"2012">>; 424 | year(2013) -> <<"2013">>; 425 | year(2014) -> <<"2014">>; 426 | year(2015) -> <<"2015">>; 427 | year(2016) -> <<"2016">>; 428 | year(2017) -> <<"2017">>; 429 | year(2018) -> <<"2018">>; 430 | year(2019) -> <<"2019">>; 431 | year(2020) -> <<"2020">>; 432 | year(2021) -> <<"2021">>; 433 | year(2022) -> <<"2022">>; 434 | year(2023) -> <<"2023">>; 435 | year(2024) -> <<"2024">>; 436 | year(2025) -> <<"2025">>; 437 | year(2026) -> <<"2026">>; 438 | year(2027) -> <<"2027">>; 439 | year(2028) -> <<"2028">>; 440 | year(2029) -> <<"2029">>; 441 | year(Year) -> integer_to_binary(Year). 442 | --------------------------------------------------------------------------------