├── test ├── test_helper.exs └── plug │ ├── crypto │ ├── message_encryptor_test.exs │ ├── key_generator_test.exs │ └── message_verifier_test.exs │ └── crypto_test.exs ├── .formatter.exs ├── lib └── plug │ ├── crypto │ ├── application.ex │ ├── message_verifier.ex │ ├── key_generator.ex │ └── message_encryptor.ex │ └── crypto.ex ├── LICENSE ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── README.md ├── mix.exs ├── mix.lock └── CHANGELOG.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/plug/crypto/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Plug.Crypto.Application do 2 | @moduledoc false 3 | use Application 4 | 5 | def start(_, _) do 6 | children = [ 7 | {Agent, &start_crypto_keys/0} 8 | ] 9 | 10 | Supervisor.start_link(children, strategy: :one_for_one, name: __MODULE__) 11 | end 12 | 13 | defp start_crypto_keys do 14 | Plug.Crypto.Keys = :ets.new(Plug.Crypto.Keys, [:named_table, :public, read_concurrency: true]) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Plataformatec. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /.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 3rd-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_crypto-*.tar 24 | 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | runs-on: ${{ matrix.os }} 12 | env: 13 | MIX_ENV: test 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | include: 18 | - elixir: "1.14" 19 | otp: "24" 20 | os: ubuntu-22.04 21 | 22 | - elixir: "1.19" 23 | otp: "28" 24 | lint: lint 25 | os: ubuntu-latest 26 | 27 | steps: 28 | - uses: actions/checkout@v5 29 | 30 | - uses: erlef/setup-beam@v1 31 | with: 32 | otp-version: ${{ matrix.otp }} 33 | elixir-version: ${{ matrix.elixir }} 34 | 35 | - run: mix deps.get --only test 36 | 37 | - run: mix format --check-formatted 38 | if: ${{ matrix.lint }} 39 | 40 | - run: mix do deps.get, deps.unlock --check-unused 41 | if: ${{ matrix.lint }} 42 | 43 | - run: mix compile --warnings-as-errors 44 | if: ${{ matrix.lint }} 45 | 46 | - run: mix test 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plug.Crypto 2 | 3 | [![Hex.pm Version](https://img.shields.io/hexpm/v/plug_crypto.svg)](https://hex.pm/packages/plug_crypto) 4 | [![Build Status](https://github.com/elixir-plug/plug_crypto/workflows/CI/badge.svg)](https://github.com/elixir-plug/plug_crypto/actions?query=workflow%3ACI) 5 | 6 | Crypto-related functionality for web applications, used by [Plug](https://github.com/elixir-plug/plug). 7 | 8 | ## Installation 9 | 10 | You can use plug_crypto in your projects by adding it to your `mix.exs` dependencies: 11 | 12 | ```elixir 13 | def deps do 14 | [{:plug_crypto, "~> 2.0"}] 15 | end 16 | ``` 17 | 18 | If you're using [Plug](https://github.com/elixir-plug/plug), you can already use the functionality in plug_crypto since Plug depends on it. 19 | 20 | ## Contributing 21 | 22 | We welcome everyone to contribute to Plug.Crypto and help us tackle existing issues! 23 | 24 | - Use the [issue tracker](https://github.com/elixir-plug/plug_crypto/issues) for bug reports or feature requests. 25 | - Open a [pull request](https://github.com/elixir-plug/plug_crypto/pulls) when you are ready to contribute. 26 | - Do not update the `CHANGELOG.md` when submitting a pull request. 27 | 28 | ## License 29 | 30 | Plug.Crypto source code is released under Apache License 2.0. Check the [LICENSE](./LICENSE) file for more information. 31 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Plug.Crypto.MixProject do 2 | use Mix.Project 3 | 4 | @version "2.1.1" 5 | @description "Crypto-related functionality for the web" 6 | @source_url "https://github.com/elixir-plug/plug_crypto" 7 | 8 | def project do 9 | [ 10 | app: :plug_crypto, 11 | version: @version, 12 | elixir: "~> 1.14", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | package: package(), 16 | name: "Plug.Crypto", 17 | description: @description, 18 | docs: [ 19 | main: "Plug.Crypto", 20 | source_ref: "v#{@version}", 21 | source_url: @source_url, 22 | extras: ["CHANGELOG.md"] 23 | ] 24 | ] 25 | end 26 | 27 | def application do 28 | [ 29 | extra_applications: [:crypto], 30 | mod: {Plug.Crypto.Application, []} 31 | ] 32 | end 33 | 34 | defp deps do 35 | [{:ex_doc, "~> 0.21", only: :dev}] 36 | end 37 | 38 | defp package do 39 | %{ 40 | licenses: ["Apache-2.0"], 41 | maintainers: [ 42 | "Aleksei Magusev", 43 | "Andrea Leopardi", 44 | "Eric Meadows-Jönsson", 45 | "Gary Rennie", 46 | "José Valim" 47 | ], 48 | links: %{"GitHub" => @source_url}, 49 | files: ["lib", "mix.exs", "README.md", "CHANGELOG.md", "LICENSE"] 50 | } 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /test/plug/crypto/message_encryptor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Plug.Crypto.MessageEncryptorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Plug.Crypto.MessageEncryptor, as: ME 5 | alias Plug.Crypto.{MessageEncryptor, KeyGenerator} 6 | doctest MessageEncryptor 7 | 8 | @right String.duplicate("abcdefgh", 4) 9 | @wrong String.duplicate("12345678", 4) 10 | 11 | test "it encrypts/decrypts a message" do 12 | data = <<0, "hełłoworld", 0>> 13 | encrypted = ME.encrypt(data, "right aad", @right, "UNUSED") 14 | assert ME.decrypt(encrypted, "right aad", @wrong, "UNUSED") == :error 15 | assert ME.decrypt(encrypted, "wrong aad", @right, "UNUSED") == :error 16 | assert ME.decrypt(encrypted, "right aad", @right, "UNUSED") == {:ok, data} 17 | end 18 | 19 | test "it encrypts/decrypts with iodata aad" do 20 | data = <<0, "hełłoworld", 0>> 21 | encrypted = ME.encrypt(data, ["right", ?\s, "aad"], @right, @right) 22 | assert ME.decrypt(encrypted, ["right", ?\s, "aad"], @right, @right) == {:ok, data} 23 | end 24 | 25 | @old_message "QTEyOEdDTQ.L85cCXPvSqswNJoxmP5QTopFY83qCPj9czxkwct8b0HDHdC8Qwruhkq3SWw.mmqfbc2dfaMMi6Xi.n1qvYhAUYI0r7-QB6Vw.0jV2tT3U-AQMAQSch2rNsw" 26 | 27 | test "it decodes messages from earlier versions" do 28 | data = <<0, "hełłoworld", 0>> 29 | assert ME.decrypt(@old_message, "right aad", @right, @right) == {:ok, data} 30 | assert ME.decrypt(@old_message, "wrong aad", @right, @right) == :error 31 | assert ME.decrypt(@old_message, "right aad", @wrong, @right) == :error 32 | assert ME.decrypt(@old_message, "right aad", @right, @wrong) == :error 33 | assert ME.decrypt(@old_message, "right aad", @wrong, @wrong) == :error 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 3 | "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, 4 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 6 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 7 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.1.1 (2025-04-03) 4 | 5 | * Fall back `hash_equals` when missing OpenSSL support 6 | 7 | ## v2.1.0 (2024-05-02) 8 | 9 | * Use `System.os_time/1` as the token signing date, since tokens are meant to be shared across machines 10 | 11 | ## v2.0.0 (2023-10-06) 12 | 13 | * Update Elixir requirement to v1.11+ and require Erlang/OTP 23. 14 | * Encryption now uses XChaCha20-Poly1305, which is safer, faster, and generates smaller payloads. This means data encrypted with Plug.Crypto v2.0 cannot be decrypted on Plug.Crypto v1.x. However, Plug.Crypto v2.0 can still decrypt data from Plug.Crypto v1.0. 15 | * XChaCha20-Poly1305 requires that the underlying Erlang/OTP is compiled with OpenSSL 1.1.0 or newer. 16 | * Optimize `secure_compare`, `masked_compare`, and key generator algorithms by relying on `:crypto` code when using more recent Erlang/OTP versions. 17 | 18 | ## v1.2.5 (2023-03-10) 19 | 20 | * Allow AAD to be given as iolist 21 | 22 | ## v1.2.4 (2023-03-10) 23 | 24 | * Allow AAD to be given as argument on message encryptor 25 | 26 | ## v1.2.3 (2022-08-19) 27 | 28 | * Remove warnings on Elixir v1.14 29 | 30 | ## v1.2.2 (2021-03-25) 31 | 32 | * Remove warnings on Elixir v1.12 33 | 34 | ## v1.2.1 (2021-02-17) 35 | 36 | * Add support for Erlang/OTP 24 37 | 38 | ## v1.2.0 (2020-10-07) 39 | 40 | * Update Elixir requirement to Elixir 1.7+. 41 | * Fixed a bug that allowed to sign and encrypt stuff with `nil` secret key base and salt. 42 | 43 | ## v1.1.2 (2020-02-16) 44 | 45 | * Do not key derive empty salts (default to no salt instead). 46 | 47 | ## v1.1.1 (2020-02-14) 48 | 49 | * Do not expose encryption with salt API. 50 | * Allow default `:max_age` to be set when signing/encrypting. 51 | 52 | ## v1.1.0 (2020-02-11) 53 | 54 | * Add high-level `Plug.Crypto.sign/verify` and `Plug.Crypto.encrypt/decrypt`. 55 | 56 | ## v1.0.0 (2018-10-03) 57 | 58 | * Split up the `plug_crypto` project from Plug as per [elixir-lang/plug#766](https://github.com/elixir-plug/plug/issues/766). 59 | -------------------------------------------------------------------------------- /lib/plug/crypto/message_verifier.ex: -------------------------------------------------------------------------------- 1 | defmodule Plug.Crypto.MessageVerifier do 2 | @moduledoc """ 3 | `MessageVerifier` makes it easy to generate and verify messages 4 | which are signed to prevent tampering. 5 | 6 | For example, the cookie store uses this verifier to send data 7 | to the client. The data can be read by the client, but cannot be 8 | tampered with. 9 | 10 | The message and its verification are base64url encoded and returned 11 | to you. 12 | 13 | The current algorithm used is HMAC-SHA, with SHA256, SHA384, and 14 | SHA512 as supported digest types. 15 | """ 16 | 17 | @doc """ 18 | Signs a message according to the given secret. 19 | """ 20 | def sign(message, secret, digest_type \\ :sha256) 21 | when is_binary(message) and byte_size(secret) > 0 and 22 | digest_type in [:sha256, :sha384, :sha512] do 23 | hmac_sha2_sign(message, secret, digest_type) 24 | rescue 25 | e -> reraise e, Plug.Crypto.prune_args_from_stacktrace(__STACKTRACE__) 26 | end 27 | 28 | @doc """ 29 | Decodes and verifies the encoded binary was not tampered with. 30 | """ 31 | def verify(signed, secret) when is_binary(signed) and byte_size(secret) > 0 do 32 | hmac_sha2_verify(signed, secret) 33 | rescue 34 | e -> reraise e, Plug.Crypto.prune_args_from_stacktrace(__STACKTRACE__) 35 | end 36 | 37 | ## Signature Algorithms 38 | 39 | defp hmac_sha2_to_protected(:sha256), do: "SFMyNTY" 40 | defp hmac_sha2_to_protected(:sha384), do: "SFMzODQ" 41 | defp hmac_sha2_to_protected(:sha512), do: "SFM1MTI" 42 | 43 | defp hmac_sha2_to_digest_type("SFMyNTY"), do: :sha256 44 | defp hmac_sha2_to_digest_type("SFMzODQ"), do: :sha384 45 | defp hmac_sha2_to_digest_type("SFM1MTI"), do: :sha512 46 | 47 | defp hmac_sha2_sign(payload, key, digest_type) do 48 | protected = hmac_sha2_to_protected(digest_type) 49 | plain_text = [protected, ?., Base.url_encode64(payload, padding: false)] 50 | signature = :crypto.mac(:hmac, digest_type, key, plain_text) 51 | IO.iodata_to_binary([plain_text, ".", Base.url_encode64(signature, padding: false)]) 52 | end 53 | 54 | defp hmac_sha2_verify(signed, key) when is_binary(signed) and is_binary(key) do 55 | with [protected, payload, signature] when protected in ["SFMyNTY", "SFMzODQ", "SFM1MTI"] <- 56 | :binary.split(signed, ".", [:global]), 57 | plain_text = [protected, ?., payload], 58 | {:ok, payload} <- Base.url_decode64(payload, padding: false), 59 | {:ok, signature} <- Base.url_decode64(signature, padding: false) do 60 | digest_type = hmac_sha2_to_digest_type(protected) 61 | challenge = :crypto.mac(:hmac, digest_type, key, plain_text) 62 | 63 | if Plug.Crypto.secure_compare(challenge, signature) do 64 | {:ok, payload} 65 | else 66 | :error 67 | end 68 | else 69 | _ -> 70 | :error 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/plug/crypto/key_generator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Plug.Crypto.KeyGeneratorTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Bitwise 5 | 6 | @max_length bsl(1, 32) - 1 7 | 8 | test "returns an error for length exceeds max_length" do 9 | assert_raise ArgumentError, ~r/length must be less than or equal/, fn -> 10 | generate("secret", "salt", length: @max_length + 1) 11 | end 12 | end 13 | 14 | test "returns an error if iterations is not an integer >= 1" do 15 | for i <- [32.0, -1, nil, "many", :lots] do 16 | assert_raise ArgumentError, "iterations must be an integer >= 1", fn -> 17 | generate("secret", "salt", iterations: i) 18 | end 19 | end 20 | end 21 | 22 | test "digest :sha works" do 23 | key = generate("password", "salt", iterations: 1, length: 20, digest: :sha) 24 | assert byte_size(key) == 20 25 | assert to_hex(key) == "0c60c80f961f0e71f3a9b524af6012062fe037a6" 26 | 27 | key = generate("password", "salt", iterations: 2, length: 20, digest: :sha) 28 | assert byte_size(key) == 20 29 | assert to_hex(key) == "ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957" 30 | 31 | key = generate("password", "salt", iterations: 4096, length: 20, digest: :sha) 32 | assert byte_size(key) == 20 33 | assert to_hex(key) == "4b007901b765489abead49d926f721d065a429c1" 34 | 35 | key = 36 | generate( 37 | "passwordPASSWORDpassword", 38 | "saltSALTsaltSALTsaltSALTsaltSALTsalt", 39 | iterations: 4096, 40 | length: 25, 41 | digest: :sha 42 | ) 43 | 44 | assert byte_size(key) == 25 45 | assert to_hex(key) == "3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038" 46 | 47 | key = generate("pass\0word", "sa\0lt", iterations: 4096, length: 16, digest: :sha) 48 | assert byte_size(key) == 16 49 | assert to_hex(key) == "56fa6aa75548099dcc37d7f03425e0c3" 50 | 51 | key = generate("password", "salt", digest: :sha) 52 | assert byte_size(key) == 32 53 | assert to_hex(key) == "6e88be8bad7eae9d9e10aa061224034fed48d03fcbad968b56006784539d5214" 54 | 55 | key = generate("password", "salt") 56 | assert byte_size(key) == 32 57 | assert to_hex(key) == "632c2812e46d4604102ba7618e9d6d7d2f8128f6266b4a03264d2a0460b7dcb3" 58 | 59 | key = generate("password", "salt", iterations: 1000, length: 64, digest: :sha) 60 | assert byte_size(key) == 64 61 | 62 | assert to_hex(key) == 63 | "6e88be8bad7eae9d9e10aa061224034fed48d03fcbad968b56006784539d5214ce970d912ec2049b04231d47c2eb88506945b26b2325e6adfeeba08895ff9587" 64 | end 65 | 66 | test "digest :sha224 works" do 67 | key = generate("password", "salt", iterations: 1, length: 16, digest: :sha224) 68 | assert byte_size(key) == 16 69 | assert to_hex(key) == "3c198cbdb9464b7857966bd05b7bc92b" 70 | end 71 | 72 | test "digest :sha256 works" do 73 | key = generate("password", "salt", iterations: 1, length: 16, digest: :sha256) 74 | assert byte_size(key) == 16 75 | assert to_hex(key) == "120fb6cffcf8b32c43e7225256c4f837" 76 | end 77 | 78 | test "digest :sha384 works" do 79 | key = generate("password", "salt", iterations: 1, length: 16, digest: :sha384) 80 | assert byte_size(key) == 16 81 | assert to_hex(key) == "c0e14f06e49e32d73f9f52ddf1d0c5c7" 82 | end 83 | 84 | test "digest :sha512 works" do 85 | key = generate("password", "salt", iterations: 1, length: 16, digest: :sha512) 86 | assert byte_size(key) == 16 87 | assert to_hex(key) == "867f70cf1ade02cff3752599a3a53dc4" 88 | end 89 | 90 | def generate(secret, salt, opts \\ []) do 91 | Plug.Crypto.KeyGenerator.generate(secret, salt, opts) 92 | end 93 | 94 | def to_hex(value), do: Base.encode16(value, case: :lower) 95 | end 96 | -------------------------------------------------------------------------------- /test/plug/crypto/message_verifier_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Plug.Crypto.MessageVerifierTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Plug.Crypto.MessageVerifier, as: MV 5 | 6 | test "generates a signed message" do 7 | [protected, payload, signature] = String.split(MV.sign("hello world", "secret"), ".") 8 | assert Base.url_decode64(protected, padding: false) == {:ok, "HS256"} 9 | assert Base.url_decode64(payload, padding: false) == {:ok, "hello world"} 10 | assert byte_size(signature) == 43 11 | 12 | [protected, payload, signature] = String.split(MV.sign("hello world", "secret", :sha384), ".") 13 | assert Base.url_decode64(protected, padding: false) == {:ok, "HS384"} 14 | assert Base.url_decode64(payload, padding: false) == {:ok, "hello world"} 15 | assert byte_size(signature) == 64 16 | 17 | [protected, payload, signature] = String.split(MV.sign("hello world", "secret", :sha512), ".") 18 | assert Base.url_decode64(protected, padding: false) == {:ok, "HS512"} 19 | assert Base.url_decode64(payload, padding: false) == {:ok, "hello world"} 20 | assert byte_size(signature) == 86 21 | end 22 | 23 | test "verifies a signed message" do 24 | [protected, payload, signature] = String.split(MV.sign("hello world", "secret"), ".") 25 | 26 | assert MV.verify(protected <> "." <> payload <> "." <> signature, "secret") == 27 | {:ok, "hello world"} 28 | 29 | [protected, payload, signature] = String.split(MV.sign("hello world", "secret", :sha384), ".") 30 | 31 | assert MV.verify(protected <> "." <> payload <> "." <> signature, "secret") == 32 | {:ok, "hello world"} 33 | 34 | [protected, payload, signature] = String.split(MV.sign("hello world", "secret", :sha512), ".") 35 | 36 | assert MV.verify(protected <> "." <> payload <> "." <> signature, "secret") == 37 | {:ok, "hello world"} 38 | end 39 | 40 | test "does not verify a signed message if secret changed" do 41 | signed = MV.sign("hello world", "secret") 42 | assert MV.verify(signed, "secreto") == :error 43 | 44 | signed = MV.sign("hello world", "secret", :sha384) 45 | assert MV.verify(signed, "secreto") == :error 46 | 47 | signed = MV.sign("hello world", "secret", :sha512) 48 | assert MV.verify(signed, "secreto") == :error 49 | end 50 | 51 | test "does not verify a tampered message" do 52 | # Tampered payload 53 | payload = Base.url_encode64("another world", padding: false) 54 | [protected, _payload, signature] = String.split(MV.sign("hello world", "secret"), ".") 55 | assert MV.verify(protected <> "." <> payload <> "." <> signature, "secret") == :error 56 | 57 | [protected, _payload, signature] = 58 | String.split(MV.sign("hello world", "secret", :sha384), ".") 59 | 60 | assert MV.verify(protected <> "." <> payload <> "." <> signature, "secret") == :error 61 | 62 | [protected, _payload, signature] = 63 | String.split(MV.sign("hello world", "secret", :sha512), ".") 64 | 65 | assert MV.verify(protected <> "." <> payload <> "." <> signature, "secret") == :error 66 | 67 | # Tampered protected 68 | [_protected, payload, signature] = String.split(MV.sign("hello world", "secret"), ".") 69 | protected = Base.url_encode64("HS384", padding: false) 70 | assert MV.verify(protected <> "." <> payload <> "." <> signature, "secret") == :error 71 | 72 | [_protected, payload, signature] = 73 | String.split(MV.sign("hello world", "secret", :sha384), ".") 74 | 75 | protected = Base.url_encode64("HS512", padding: false) 76 | assert MV.verify(protected <> "." <> payload <> "." <> signature, "secret") == :error 77 | 78 | [_protected, payload, signature] = 79 | String.split(MV.sign("hello world", "secret", :sha512), ".") 80 | 81 | protected = Base.url_encode64("HS256", padding: false) 82 | assert MV.verify(protected <> "." <> payload <> "." <> signature, "secret") == :error 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/plug/crypto/key_generator.ex: -------------------------------------------------------------------------------- 1 | defmodule Plug.Crypto.KeyGenerator do 2 | @moduledoc """ 3 | `KeyGenerator` implements PBKDF2 (Password-Based Key Derivation Function 2), 4 | part of PKCS #5 v2.0 (Password-Based Cryptography Specification). 5 | 6 | It can be used to derive a number of keys for various purposes from a given 7 | secret. This lets applications have a single secure secret, but avoid reusing 8 | that key in multiple incompatible contexts. 9 | 10 | The returned key is a binary. You may invoke functions in the `Base` module, 11 | such as `Base.url_encode64/2`, to convert this binary into a textual 12 | representation. 13 | 14 | See http://tools.ietf.org/html/rfc2898#section-5.2 15 | """ 16 | 17 | import Bitwise 18 | @max_length bsl(1, 32) - 1 19 | 20 | @doc """ 21 | Returns a derived key suitable for use. 22 | 23 | ## Options 24 | 25 | * `:iterations` - defaults to 1000 (increase to at least 2^16 if used for passwords); 26 | * `:length` - a length in octets for the derived key. Defaults to 32; 27 | * `:digest` - an hmac function to use as the pseudo-random function. Defaults to `:sha256`; 28 | * `:cache` - an ETS table name to be used as cache. 29 | Only use an ETS table as cache if the secret and salt is a bound set of values. 30 | For example: `:ets.new(:your_name, [:named_table, :public, read_concurrency: true])` 31 | 32 | """ 33 | def generate(secret, salt, opts \\ []) do 34 | iterations = Keyword.get(opts, :iterations, 1000) 35 | length = Keyword.get(opts, :length, 32) 36 | digest = Keyword.get(opts, :digest, :sha256) 37 | cache = Keyword.get(opts, :cache) 38 | generate(secret, salt, iterations, length, digest, cache) 39 | end 40 | 41 | @doc false 42 | def generate(secret, salt, iterations, length, digest, cache) do 43 | cond do 44 | not is_integer(iterations) or iterations < 1 -> 45 | raise ArgumentError, "iterations must be an integer >= 1" 46 | 47 | length > @max_length -> 48 | raise ArgumentError, "length must be less than or equal to #{@max_length}" 49 | 50 | true -> 51 | with_cache(cache, {secret, salt, iterations, length, digest}, fn -> 52 | pbkdf2_hmac(digest, secret, salt, iterations, length) 53 | end) 54 | end 55 | rescue 56 | e -> reraise e, Plug.Crypto.prune_args_from_stacktrace(__STACKTRACE__) 57 | end 58 | 59 | defp with_cache(nil, _key, fun), do: fun.() 60 | 61 | defp with_cache(ets, key, fun) do 62 | case :ets.lookup(ets, key) do 63 | [{_key, value}] -> 64 | value 65 | 66 | [] -> 67 | value = fun.() 68 | :ets.insert(ets, [{key, value}]) 69 | value 70 | end 71 | end 72 | 73 | # TODO: remove when we require OTP 24.2 74 | if Code.ensure_loaded?(:crypto) and function_exported?(:crypto, :pbkdf2_hmac, 5) do 75 | defp pbkdf2_hmac(digest, secret, salt, iterations, length) do 76 | :crypto.pbkdf2_hmac(digest, secret, salt, iterations, length) 77 | end 78 | else 79 | defp pbkdf2_hmac(digest, secret, salt, iterations, length) do 80 | legacy_pbkdf2_hmac(hmac_fun(digest, secret), salt, iterations, length, 1, [], 0) 81 | end 82 | 83 | defp legacy_pbkdf2_hmac(_fun, _salt, _iterations, max_length, _block_index, acc, length) 84 | when length >= max_length do 85 | acc 86 | |> IO.iodata_to_binary() 87 | |> binary_part(0, max_length) 88 | end 89 | 90 | defp legacy_pbkdf2_hmac(fun, salt, iterations, max_length, block_index, acc, length) do 91 | initial = fun.(<>) 92 | block = iterate(fun, iterations - 1, initial, initial) 93 | length = byte_size(block) + length 94 | 95 | legacy_pbkdf2_hmac( 96 | fun, 97 | salt, 98 | iterations, 99 | max_length, 100 | block_index + 1, 101 | [acc | block], 102 | length 103 | ) 104 | end 105 | 106 | defp iterate(_fun, 0, _prev, acc), do: acc 107 | 108 | defp iterate(fun, iteration, prev, acc) do 109 | next = fun.(prev) 110 | iterate(fun, iteration - 1, next, :crypto.exor(next, acc)) 111 | end 112 | 113 | defp hmac_fun(digest, key), do: &:crypto.mac(:hmac, digest, key, &1) 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/plug/crypto/message_encryptor.ex: -------------------------------------------------------------------------------- 1 | defmodule Plug.Crypto.MessageEncryptor do 2 | @moduledoc ~S""" 3 | `MessageEncryptor` is a simple way to encrypt values which get stored 4 | somewhere you don't trust. 5 | 6 | The encrypted key, initialization vector, cipher text, and cipher tag 7 | are base64url encoded and returned to you. 8 | 9 | This can be used in situations similar to the `Plug.Crypto.MessageVerifier`, 10 | but where you don't want users to be able to determine the value of the payload. 11 | 12 | The current algorithm used is XChaCha20-Poly1305. 13 | 14 | ## Example 15 | 16 | iex> secret_key_base = "072d1e0157c008193fe48a670cce031faa4e..." 17 | ...> encrypted_cookie_salt = "encrypted cookie" 18 | ...> secret = KeyGenerator.generate(secret_key_base, encrypted_cookie_salt) 19 | ...> 20 | ...> data = "José" 21 | ...> encrypted = MessageEncryptor.encrypt(data, secret, "UNUSED") 22 | ...> MessageEncryptor.decrypt(encrypted, secret, "UNUSED") 23 | {:ok, "José"} 24 | 25 | """ 26 | 27 | @doc """ 28 | Encrypts a message using authenticated encryption. 29 | 30 | The `sign_secret` is currently only used on decryption 31 | for backwards compatibility. 32 | 33 | A custom authentication message can be provided. 34 | It defaults to "A128GCM" for backwards compatibility. 35 | """ 36 | def encrypt(message, aad \\ "A128GCM", secret, sign_secret) 37 | when is_binary(message) and (is_binary(aad) or is_list(aad)) and 38 | bit_size(secret) == 256 and 39 | is_binary(sign_secret) do 40 | iv = :crypto.strong_rand_bytes(24) 41 | {subkey, nonce} = xchacha20_subkey_and_nonce(secret, iv) 42 | {cipher_text, cipher_tag} = block_encrypt(:chacha20_poly1305, subkey, nonce, {aad, message}) 43 | "XCP." <> Base.url_encode64(iv <> cipher_tag <> cipher_text, padding: false) 44 | rescue 45 | e -> reraise e, Plug.Crypto.prune_args_from_stacktrace(__STACKTRACE__) 46 | end 47 | 48 | @doc """ 49 | Decrypts a message using authenticated encryption. 50 | """ 51 | def decrypt(encrypted, aad \\ "A128GCM", secret, sign_secret) 52 | when is_binary(encrypted) and (is_binary(aad) or is_list(aad)) and 53 | bit_size(secret) in [128, 192, 256] and 54 | is_binary(sign_secret) do 55 | unguarded_decrypt(encrypted, aad, secret, sign_secret) 56 | rescue 57 | e -> reraise e, Plug.Crypto.prune_args_from_stacktrace(__STACKTRACE__) 58 | end 59 | 60 | defp unguarded_decrypt("XCP." <> iv_cipher_text_cipher_tag, aad, secret, _sign_secret) do 61 | with {:ok, <>} <- 62 | Base.url_decode64(iv_cipher_text_cipher_tag, padding: false), 63 | {subkey, nonce} = xchacha20_subkey_and_nonce(secret, iv), 64 | plain_text when is_binary(plain_text) <- 65 | block_decrypt(:chacha20_poly1305, subkey, nonce, {aad, cipher_text, cipher_tag}) do 66 | {:ok, plain_text} 67 | else 68 | _ -> :error 69 | end 70 | end 71 | 72 | # Messages from Plug.Crypto v1.x 73 | defp unguarded_decrypt("QTEyOEdDTQ." <> rest, aad, secret, sign_secret) do 74 | with [encrypted_key, iv, cipher_text, cipher_tag] <- :binary.split(rest, ".", [:global]), 75 | {:ok, encrypted_key} <- Base.url_decode64(encrypted_key, padding: false), 76 | {:ok, iv} when bit_size(iv) === 96 <- Base.url_decode64(iv, padding: false), 77 | {:ok, cipher_text} <- Base.url_decode64(cipher_text, padding: false), 78 | {:ok, cipher_tag} when bit_size(cipher_tag) === 128 <- 79 | Base.url_decode64(cipher_tag, padding: false), 80 | {:ok, key} <- aes_gcm_key_unwrap(encrypted_key, secret, sign_secret), 81 | plain_text when is_binary(plain_text) <- 82 | block_decrypt(:aes_gcm, key, iv, {aad, cipher_text, cipher_tag}) do 83 | {:ok, plain_text} 84 | else 85 | _ -> :error 86 | end 87 | end 88 | 89 | defp unguarded_decrypt(_rest, _aad, _secret, _sign_secret) do 90 | :error 91 | end 92 | 93 | defp block_encrypt(cipher, key, iv, {aad, payload}) do 94 | cipher = cipher_alias(cipher, bit_size(key)) 95 | :crypto.crypto_one_time_aead(cipher, key, iv, payload, aad, true) 96 | catch 97 | :error, :notsup -> raise_notsup(cipher) 98 | end 99 | 100 | defp block_decrypt(cipher, key, iv, {aad, payload, tag}) do 101 | cipher = cipher_alias(cipher, bit_size(key)) 102 | :crypto.crypto_one_time_aead(cipher, key, iv, payload, aad, tag, false) 103 | catch 104 | :error, :notsup -> raise_notsup(cipher) 105 | end 106 | 107 | defp cipher_alias(:aes_gcm, 128), do: :aes_128_gcm 108 | defp cipher_alias(:aes_gcm, 192), do: :aes_192_gcm 109 | defp cipher_alias(:aes_gcm, 256), do: :aes_256_gcm 110 | defp cipher_alias(other, _), do: other 111 | 112 | defp raise_notsup(algo) do 113 | raise "the algorithm #{inspect(algo)} is not supported by your Erlang/OTP installation. " <> 114 | "Please make sure it was compiled with the correct OpenSSL/BoringSSL bindings" 115 | end 116 | 117 | defp xchacha20_subkey_and_nonce(<>, <>) do 118 | subkey = hchacha20(key, nonce0) 119 | nonce = <<0::32, nonce1::64-bits>> 120 | {subkey, nonce} 121 | end 122 | 123 | defp hchacha20(<>, <>) do 124 | # ChaCha20 has an internal blocksize of 512-bits (64-bytes). 125 | # Let's use a Mask of random 64-bytes to blind the intermediate keystream. 126 | mask = <> = :crypto.strong_rand_bytes(64) 127 | 128 | <> = 129 | :crypto.crypto_one_time(:chacha20, key, nonce, mask, true) 130 | 131 | << 132 | x00::32-unsigned-little-integer, 133 | x01::32-unsigned-little-integer, 134 | x02::32-unsigned-little-integer, 135 | x03::32-unsigned-little-integer, 136 | x12::32-unsigned-little-integer, 137 | x13::32-unsigned-little-integer, 138 | x14::32-unsigned-little-integer, 139 | x15::32-unsigned-little-integer 140 | >> = 141 | :crypto.exor( 142 | <>, 143 | <> 144 | ) 145 | 146 | ## The final step of ChaCha20 is `State2 = State0 + State1', so let's 147 | ## recover `State1' with subtraction: `State1 = State2 - State0' 148 | << 149 | y00::32-unsigned-little-integer, 150 | y01::32-unsigned-little-integer, 151 | y02::32-unsigned-little-integer, 152 | y03::32-unsigned-little-integer, 153 | y12::32-unsigned-little-integer, 154 | y13::32-unsigned-little-integer, 155 | y14::32-unsigned-little-integer, 156 | y15::32-unsigned-little-integer 157 | >> = <<"expand 32-byte k", nonce::128-bits>> 158 | 159 | << 160 | x00 - y00::32-unsigned-little-integer, 161 | x01 - y01::32-unsigned-little-integer, 162 | x02 - y02::32-unsigned-little-integer, 163 | x03 - y03::32-unsigned-little-integer, 164 | x12 - y12::32-unsigned-little-integer, 165 | x13 - y13::32-unsigned-little-integer, 166 | x14 - y14::32-unsigned-little-integer, 167 | x15 - y15::32-unsigned-little-integer 168 | >> 169 | end 170 | 171 | # Unwraps an encrypted content encryption key (CEK) with secret and 172 | # sign_secret using AES GCM mode. Accepts keys of 128, 192, or 256 173 | # bits based on the length of the secret key. 174 | # 175 | # See: https://tools.ietf.org/html/rfc7518#section-4.7 176 | defp aes_gcm_key_unwrap(wrapped_cek, secret, sign_secret) 177 | when bit_size(secret) in [128, 192, 256] and is_binary(sign_secret) do 178 | wrapped_cek 179 | |> case do 180 | <> -> 181 | block_decrypt(:aes_gcm, secret, iv, {sign_secret, cipher_text, cipher_tag}) 182 | 183 | <> -> 184 | block_decrypt(:aes_gcm, secret, iv, {sign_secret, cipher_text, cipher_tag}) 185 | 186 | <> -> 187 | block_decrypt(:aes_gcm, secret, iv, {sign_secret, cipher_text, cipher_tag}) 188 | 189 | _ -> 190 | :error 191 | end 192 | |> case do 193 | cek when bit_size(cek) in [128, 192, 256] -> {:ok, cek} 194 | _ -> :error 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /test/plug/crypto_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Plug.CryptoTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Plug.Crypto 5 | 6 | test "prunes stacktrace" do 7 | assert prune_args_from_stacktrace([{:erlang, :+, 2, []}]) == [{:erlang, :+, 2, []}] 8 | assert prune_args_from_stacktrace([{:erlang, :+, [1, 2], []}]) == [{:erlang, :+, 2, []}] 9 | end 10 | 11 | test "masks tokens" do 12 | assert mask(<<0, 1, 0, 1>>, <<0, 1, 1, 0>>) == <<0, 0, 1, 1>> 13 | assert mask(<<0, 0, 1, 1>>, <<0, 1, 1, 0>>) == <<0, 1, 0, 1>> 14 | end 15 | 16 | test "compares binaries securely" do 17 | assert secure_compare(<<>>, <<>>) 18 | assert secure_compare(<<0>>, <<0>>) 19 | 20 | refute secure_compare(<<>>, <<1>>) 21 | refute secure_compare(<<1>>, <<>>) 22 | refute secure_compare(<<0>>, <<1>>) 23 | end 24 | 25 | test "compares masked binaries securely" do 26 | assert masked_compare(<<>>, <<>>, <<>>) 27 | assert masked_compare(<<0>>, <<0>>, <<0>>) 28 | assert masked_compare(<<0, 1, 0, 1>>, <<0, 0, 1, 1>>, <<0, 1, 1, 0>>) 29 | 30 | refute masked_compare(<<>>, <<1>>, <<0>>) 31 | refute masked_compare(<<1>>, <<>>, <<0>>) 32 | refute masked_compare(<<0>>, <<1>>, <<0>>) 33 | end 34 | 35 | test "non_executable_binary_to_term" do 36 | value = %{1 => {:foo, ["bar", 2.0, %URI{}, [self() | make_ref()], <<0::4>>]}} 37 | assert non_executable_binary_to_term(:erlang.term_to_binary(value)) == value 38 | 39 | assert_raise ArgumentError, fn -> 40 | non_executable_binary_to_term(:erlang.term_to_binary(%{1 => {:foo, [fn -> :bar end]}})) 41 | end 42 | 43 | assert_raise ArgumentError, fn -> 44 | non_executable_binary_to_term(<<131, 100, 0, 7, 103, 114, 105, 102, 102, 105, 110>>, [:safe]) 45 | end 46 | end 47 | 48 | @key "abc123" 49 | 50 | describe "sign and verify" do 51 | test "token with string" do 52 | token = sign(@key, "id", 1) 53 | assert verify(@key, "id", token) == {:ok, 1} 54 | end 55 | 56 | test "fails on missing token" do 57 | assert verify(@key, "id", nil) == {:error, :missing} 58 | end 59 | 60 | test "fails on invalid token" do 61 | token = sign(@key, "id", 1) 62 | 63 | assert verify(@key, "id", "garbage") == 64 | {:error, :invalid} 65 | 66 | assert verify(@key, "not_id", token) == 67 | {:error, :invalid} 68 | end 69 | 70 | test "supports max age in seconds" do 71 | token = sign(@key, "id", 1) 72 | assert verify(@key, "id", token, max_age: 1000) == {:ok, 1} 73 | assert verify(@key, "id", token, max_age: -1000) == {:error, :expired} 74 | assert verify(@key, "id", token, max_age: 100) == {:ok, 1} 75 | assert verify(@key, "id", token, max_age: -100) == {:error, :expired} 76 | 77 | token = sign(@key, "id", 1) 78 | assert verify(@key, "id", token, max_age: 0.1) == {:ok, 1} 79 | Process.sleep(150) 80 | assert verify(@key, "id", token, max_age: 0.1) == {:error, :expired} 81 | end 82 | 83 | test "supports max age in seconds on encryption" do 84 | token = sign(@key, "id", 1, max_age: 1000) 85 | assert verify(@key, "id", token) == {:ok, 1} 86 | 87 | token = sign(@key, "id", 1, max_age: -1000) 88 | assert verify(@key, "id", token) == {:error, :expired} 89 | assert verify(@key, "id", token, max_age: 1000) == {:ok, 1} 90 | 91 | token = sign(@key, "id", 1, max_age: 0.1) 92 | Process.sleep(150) 93 | assert verify(@key, "id", token) == {:error, :expired} 94 | end 95 | 96 | test "supports :infinity for max age" do 97 | token = sign(@key, "id", 1) 98 | assert verify(@key, "id", token, max_age: :infinity) == {:ok, 1} 99 | end 100 | 101 | test "supports signed_at in seconds" do 102 | seconds_in_day = 24 * 60 * 60 103 | day_ago_seconds = System.system_time(:second) - seconds_in_day 104 | token = sign(@key, "id", 1, signed_at: day_ago_seconds) 105 | assert verify(@key, "id", token, max_age: seconds_in_day + 1) == {:ok, 1} 106 | assert verify(@key, "id", token, max_age: seconds_in_day - 1) == {:error, :expired} 107 | end 108 | 109 | test "passes key_iterations options to key generator" do 110 | signed1 = sign(@key, "id", 1, signed_at: 0, key_iterations: 1) 111 | signed2 = sign(@key, "id", 1, signed_at: 0, key_iterations: 2) 112 | assert signed1 != signed2 113 | end 114 | 115 | test "passes key_digest options to key generator" do 116 | signed1 = sign(@key, "id", 1, signed_at: 0, key_digest: :sha256) 117 | signed2 = sign(@key, "id", 1, signed_at: 0, key_digest: :sha512) 118 | assert signed1 != signed2 119 | end 120 | 121 | test "passes key_length options to key generator" do 122 | signed1 = sign(@key, "id", 1, signed_at: 0, key_length: 16) 123 | signed2 = sign(@key, "id", 1, signed_at: 0, key_length: 32) 124 | assert signed1 != signed2 125 | end 126 | 127 | test "key defaults" do 128 | signed1 = sign(@key, "id", 1, signed_at: 0) 129 | 130 | signed2 = 131 | sign(@key, "id", 1, 132 | signed_at: 0, 133 | key_length: 32, 134 | key_digest: :sha256, 135 | key_iterations: 1000 136 | ) 137 | 138 | assert signed1 == signed2 139 | end 140 | end 141 | 142 | describe "encrypt and decrypt" do 143 | test "token with string" do 144 | token = encrypt(@key, "secret", 1) 145 | assert decrypt(@key, "secret", token) == {:ok, 1} 146 | end 147 | 148 | test "fails on missing token" do 149 | assert decrypt(@key, "secret", nil) == {:error, :missing} 150 | end 151 | 152 | test "fails on invalid token" do 153 | token = encrypt(@key, "secret", 1) 154 | 155 | assert decrypt(@key, "secret", "garbage") == 156 | {:error, :invalid} 157 | 158 | assert decrypt(@key, "not_secret", token) == 159 | {:error, :invalid} 160 | end 161 | 162 | test "supports max age in seconds" do 163 | token = encrypt(@key, "secret", 1) 164 | assert decrypt(@key, "secret", token, max_age: 1000) == {:ok, 1} 165 | assert decrypt(@key, "secret", token, max_age: -1000) == {:error, :expired} 166 | assert decrypt(@key, "secret", token, max_age: 100) == {:ok, 1} 167 | assert decrypt(@key, "secret", token, max_age: -100) == {:error, :expired} 168 | 169 | token = encrypt(@key, "secret", 1) 170 | assert decrypt(@key, "secret", token, max_age: 0.1) == {:ok, 1} 171 | Process.sleep(150) 172 | assert decrypt(@key, "secret", token, max_age: 0.1) == {:error, :expired} 173 | end 174 | 175 | test "supports max age in seconds on encryption" do 176 | token = encrypt(@key, "secret", 1, max_age: 1000) 177 | assert decrypt(@key, "secret", token) == {:ok, 1} 178 | 179 | token = encrypt(@key, "secret", 1, max_age: -1000) 180 | assert decrypt(@key, "secret", token) == {:error, :expired} 181 | assert decrypt(@key, "secret", token, max_age: 1000) == {:ok, 1} 182 | 183 | token = encrypt(@key, "secret", 1, max_age: 0.1) 184 | Process.sleep(150) 185 | assert decrypt(@key, "secret", token) == {:error, :expired} 186 | end 187 | 188 | test "supports :infinity for max age" do 189 | token = encrypt(@key, "secret", 1) 190 | assert decrypt(@key, "secret", token, max_age: :infinity) == {:ok, 1} 191 | end 192 | 193 | test "supports signed_at in seconds" do 194 | seconds_in_day = 24 * 60 * 60 195 | day_ago_seconds = System.os_time(:second) - seconds_in_day 196 | token = encrypt(@key, "secret", 1, signed_at: day_ago_seconds) 197 | assert decrypt(@key, "secret", token, max_age: seconds_in_day + 1) == {:ok, 1} 198 | 199 | assert decrypt(@key, "secret", token, max_age: seconds_in_day - 1) == 200 | {:error, :expired} 201 | end 202 | 203 | test "ensures signed_at is not in future while decrypting" do 204 | token = encrypt(@key, "secret", 1, signed_at: System.os_time(:second) + 31_536_000) 205 | assert {:error, :invalid} = decrypt(@key, "secret", token) 206 | end 207 | 208 | test "passes key_iterations options to key generator" do 209 | signed1 = encrypt(@key, "secret", 1, signed_at: 0, key_iterations: 1) 210 | signed2 = encrypt(@key, "secret", 1, signed_at: 0, key_iterations: 2) 211 | assert signed1 != signed2 212 | end 213 | 214 | test "passes key_digest options to key generator" do 215 | signed1 = encrypt(@key, "secret", 1, signed_at: 0, key_digest: :sha256) 216 | signed2 = encrypt(@key, "secret", 1, signed_at: 0, key_digest: :sha512) 217 | assert signed1 != signed2 218 | end 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /lib/plug/crypto.ex: -------------------------------------------------------------------------------- 1 | defmodule Plug.Crypto do 2 | @moduledoc """ 3 | Namespace and module for crypto-related functionality. 4 | 5 | For low-level functionality, see `Plug.Crypto.KeyGenerator`, 6 | `Plug.Crypto.MessageEncryptor`, and `Plug.Crypto.MessageVerifier`. 7 | """ 8 | 9 | alias Plug.Crypto.{KeyGenerator, MessageVerifier, MessageEncryptor} 10 | 11 | @doc """ 12 | Prunes the stacktrace to remove any argument trace. 13 | 14 | This is useful when working with functions that receives secrets 15 | and we want to make sure those secrets do not leak on error messages. 16 | """ 17 | @spec prune_args_from_stacktrace(Exception.stacktrace()) :: Exception.stacktrace() 18 | def prune_args_from_stacktrace(stacktrace) 19 | 20 | def prune_args_from_stacktrace([{mod, fun, [_ | _] = args, info} | rest]), 21 | do: [{mod, fun, length(args), info} | rest] 22 | 23 | def prune_args_from_stacktrace(stacktrace) when is_list(stacktrace), 24 | do: stacktrace 25 | 26 | @doc """ 27 | A restricted version of `:erlang.binary_to_term/2` that forbids 28 | *executable* terms, such as anonymous functions. 29 | 30 | The `opts` are given to the underlying `:erlang.binary_to_term/2` 31 | call, with an empty list as a default. 32 | 33 | By default this function does not restrict atoms, as an atom 34 | interned in one node may not yet have been interned on another 35 | (except for releases, which preload all code). 36 | 37 | If you want to avoid atoms from being created, then you can pass 38 | `[:safe]` as options, as that will also enable the safety mechanisms 39 | from `:erlang.binary_to_term/2` itself. 40 | """ 41 | @spec non_executable_binary_to_term(binary(), [atom()]) :: term() 42 | def non_executable_binary_to_term(binary, opts \\ []) when is_binary(binary) do 43 | term = :erlang.binary_to_term(binary, opts) 44 | non_executable_terms(term) 45 | term 46 | end 47 | 48 | defp non_executable_terms(list) when is_list(list) do 49 | non_executable_list(list) 50 | end 51 | 52 | defp non_executable_terms(tuple) when is_tuple(tuple) do 53 | non_executable_tuple(tuple, tuple_size(tuple)) 54 | end 55 | 56 | defp non_executable_terms(map) when is_map(map) do 57 | folder = fn key, value, acc -> 58 | non_executable_terms(key) 59 | non_executable_terms(value) 60 | acc 61 | end 62 | 63 | :maps.fold(folder, map, map) 64 | end 65 | 66 | defp non_executable_terms(other) 67 | when is_atom(other) or is_number(other) or is_bitstring(other) or is_pid(other) or 68 | is_reference(other) do 69 | other 70 | end 71 | 72 | defp non_executable_terms(other) do 73 | raise ArgumentError, 74 | "cannot deserialize #{inspect(other)}, the term is not safe for deserialization" 75 | end 76 | 77 | defp non_executable_list([]), do: :ok 78 | 79 | defp non_executable_list([h | t]) when is_list(t) do 80 | non_executable_terms(h) 81 | non_executable_list(t) 82 | end 83 | 84 | defp non_executable_list([h | t]) do 85 | non_executable_terms(h) 86 | non_executable_terms(t) 87 | end 88 | 89 | defp non_executable_tuple(_tuple, 0), do: :ok 90 | 91 | defp non_executable_tuple(tuple, n) do 92 | non_executable_terms(:erlang.element(n, tuple)) 93 | non_executable_tuple(tuple, n - 1) 94 | end 95 | 96 | @doc """ 97 | Masks the token on the left with the token on the right. 98 | 99 | Both tokens are required to have the same size. 100 | """ 101 | @spec mask(binary(), binary()) :: binary() 102 | def mask(left, right) do 103 | :crypto.exor(left, right) 104 | end 105 | 106 | @doc """ 107 | Compares the two binaries (one being masked) in constant-time to avoid 108 | timing attacks. 109 | 110 | It is assumed the right token is masked according to the given mask. 111 | """ 112 | @spec masked_compare(binary(), binary(), binary()) :: boolean() 113 | def masked_compare(left, right, mask) 114 | when is_binary(left) and is_binary(right) and is_binary(mask) do 115 | byte_size(left) == byte_size(right) and byte_size(right) == byte_size(mask) and 116 | crypto_exor_hash_equals(left, right, mask) 117 | end 118 | 119 | defp crypto_exor_hash_equals(x, y, z) do 120 | crypto_hash_equals(mask(x, y), z) 121 | end 122 | 123 | @doc """ 124 | Compares the two binaries in constant-time to avoid timing attacks. 125 | 126 | See: https://en.wikipedia.org/wiki/Timing_attack 127 | """ 128 | @spec secure_compare(binary(), binary()) :: boolean() 129 | def secure_compare(left, right) when is_binary(left) and is_binary(right) do 130 | byte_size(left) == byte_size(right) and crypto_hash_equals(left, right) 131 | end 132 | 133 | # TODO: remove when we require OTP 25.0 134 | if Code.ensure_loaded?(:crypto) and function_exported?(:crypto, :hash_equals, 2) do 135 | defp crypto_hash_equals(x, y) do 136 | # Depending on the linked OpenSSL library hash_equals is available. 137 | # If not, we fall back to the legacy implementation. 138 | try do 139 | :crypto.hash_equals(x, y) 140 | rescue 141 | # Still can throw "Unsupported CRYPTO_memcmp" 142 | ErlangError -> 143 | legacy_secure_compare(x, y, 0) 144 | end 145 | end 146 | else 147 | defp crypto_hash_equals(x, y) do 148 | legacy_secure_compare(x, y, 0) 149 | end 150 | end 151 | 152 | defp legacy_secure_compare(<>, <>, acc) do 153 | import Bitwise 154 | xorred = bxor(x, y) 155 | legacy_secure_compare(left, right, acc ||| xorred) 156 | end 157 | 158 | defp legacy_secure_compare(<<>>, <<>>, acc) do 159 | acc === 0 160 | end 161 | 162 | @doc """ 163 | Encodes and signs data into a token you can send to clients. 164 | 165 | Plug.Crypto.sign(conn.secret_key_base, "user-secret", {:elixir, :terms}) 166 | 167 | A key will be derived from the secret key base and the given user secret. 168 | The key will also be cached for performance reasons on future calls. 169 | 170 | ## Options 171 | 172 | * `:key_iterations` - option passed to `Plug.Crypto.KeyGenerator` 173 | when generating the encryption and signing keys. Defaults to 1000 174 | * `:key_length` - option passed to `Plug.Crypto.KeyGenerator` 175 | when generating the encryption and signing keys. Defaults to 32 176 | * `:key_digest` - option passed to `Plug.Crypto.KeyGenerator` 177 | when generating the encryption and signing keys. Defaults to `:sha256` 178 | * `:signed_at` - set the timestamp of the token in **seconds**. 179 | If no value is provided, it will be set to the current time. 180 | * `:max_age` - the default maximum age in **seconds** of the token. Defaults to 181 | `86400` seconds (1 day) and it may be overridden on `verify/4`. 182 | 183 | """ 184 | def sign(key_base, salt, data, opts \\ []) when is_binary(key_base) and is_binary(salt) do 185 | data 186 | |> encode(opts) 187 | |> MessageVerifier.sign(get_secret(key_base, salt, opts)) 188 | end 189 | 190 | @doc """ 191 | Encodes, encrypts, and signs data into a token you can send to clients. 192 | 193 | Plug.Crypto.encrypt(conn.secret_key_base, "user-secret", {:elixir, :terms}) 194 | 195 | A key will be derived from the secret key base and the given user secret. 196 | The key will also be cached for performance reasons on future calls. 197 | 198 | ## Options 199 | 200 | * `:key_iterations` - option passed to `Plug.Crypto.KeyGenerator` 201 | when generating the encryption and signing keys. Defaults to 1000 202 | * `:key_length` - option passed to `Plug.Crypto.KeyGenerator` 203 | when generating the encryption and signing keys. Defaults to 32 204 | * `:key_digest` - option passed to `Plug.Crypto.KeyGenerator` 205 | when generating the encryption and signing keys. Defaults to `:sha256` 206 | * `:signed_at` - set the timestamp of the token in **seconds**. 207 | If no value is provided, it will be set to the current time. 208 | * `:max_age` - the default maximum age in **seconds** of the token. Defaults to 209 | `86400` seconds (1 day) and it may be overridden on `decrypt/4`. 210 | 211 | """ 212 | def encrypt(key_base, secret, data, opts \\ []) 213 | when is_binary(key_base) and is_binary(secret) do 214 | data 215 | |> encode(opts) 216 | |> MessageEncryptor.encrypt(get_secret(key_base, secret, opts), "") 217 | end 218 | 219 | defp encode(data, opts) do 220 | signed_at_seconds = Keyword.get(opts, :signed_at) 221 | signed_at_ms = if signed_at_seconds, do: trunc(signed_at_seconds * 1000), else: now_ms() 222 | max_age_in_seconds = Keyword.get(opts, :max_age, 86400) 223 | :erlang.term_to_binary({data, signed_at_ms, max_age_in_seconds}) 224 | end 225 | 226 | @doc """ 227 | Decodes the original data from the token and verifies its integrity. 228 | 229 | ## Examples 230 | 231 | In this scenario we will create a token, sign it, then provide it to a client 232 | application. The client will then use this token to authenticate requests for 233 | resources from the server. See `Plug.Crypto` summary for more info about 234 | creating tokens. 235 | 236 | iex> user_id = 99 237 | iex> secret = "kjoy3o1zeidquwy1398juxzldjlksahdk3" 238 | iex> user_salt = "user salt" 239 | iex> token = Plug.Crypto.sign(secret, user_salt, user_id) 240 | 241 | The mechanism for passing the token to the client is typically through a 242 | cookie, a JSON response body, or HTTP header. For now, assume the client has 243 | received a token it can use to validate requests for protected resources. 244 | 245 | When the server receives a request, it can use `verify/4` to determine if it 246 | should provide the requested resources to the client: 247 | 248 | iex> Plug.Crypto.verify(secret, user_salt, token, max_age: 86400) 249 | {:ok, 99} 250 | 251 | In this example, we know the client sent a valid token because `verify/4` 252 | returned a tuple of type `{:ok, user_id}`. The server can now proceed with 253 | the request. 254 | 255 | However, if the client had sent an expired or otherwise invalid token 256 | `verify/4` would have returned an error instead: 257 | 258 | iex> Plug.Crypto.verify(secret, user_salt, expired, max_age: 86400) 259 | {:error, :expired} 260 | 261 | iex> Plug.Crypto.verify(secret, user_salt, invalid, max_age: 86400) 262 | {:error, :invalid} 263 | 264 | ## Options 265 | 266 | * `:max_age` - verifies the token only if it has been generated 267 | "max age" ago in **seconds**. Defaults to the max age signed in the 268 | token (86400) 269 | * `:key_iterations` - option passed to `Plug.Crypto.KeyGenerator` 270 | when generating the encryption and signing keys. Defaults to 1000 271 | * `:key_length` - option passed to `Plug.Crypto.KeyGenerator` 272 | when generating the encryption and signing keys. Defaults to 32 273 | * `:key_digest` - option passed to `Plug.Crypto.KeyGenerator` 274 | when generating the encryption and signing keys. Defaults to `:sha256` 275 | 276 | """ 277 | def verify(key_base, salt, token, opts \\ []) 278 | 279 | def verify(key_base, salt, token, opts) 280 | when is_binary(key_base) and is_binary(salt) and is_binary(token) do 281 | secret = get_secret(key_base, salt, opts) 282 | 283 | case MessageVerifier.verify(token, secret) do 284 | {:ok, message} -> decode(message, opts) 285 | :error -> {:error, :invalid} 286 | end 287 | end 288 | 289 | def verify(_key_base, salt, nil, _opts) when is_binary(salt) do 290 | {:error, :missing} 291 | end 292 | 293 | @doc """ 294 | Decrypts the original data from the token and verifies its integrity. 295 | 296 | ## Options 297 | 298 | * `:max_age` - verifies the token only if it has been generated 299 | "max age" ago in **seconds**. A reasonable value is 1 day (86400 300 | seconds) 301 | * `:key_iterations` - option passed to `Plug.Crypto.KeyGenerator` 302 | when generating the encryption and signing keys. Defaults to 1000 303 | * `:key_length` - option passed to `Plug.Crypto.KeyGenerator` 304 | when generating the encryption and signing keys. Defaults to 32 305 | * `:key_digest` - option passed to `Plug.Crypto.KeyGenerator` 306 | when generating the encryption and signing keys. Defaults to `:sha256` 307 | 308 | """ 309 | def decrypt(key_base, secret, token, opts \\ []) 310 | 311 | def decrypt(key_base, secret, nil, opts) 312 | when is_binary(key_base) and is_binary(secret) and is_list(opts) do 313 | {:error, :missing} 314 | end 315 | 316 | def decrypt(key_base, secret, token, opts) 317 | when is_binary(key_base) and is_binary(secret) and is_list(opts) do 318 | secret = get_secret(key_base, secret, opts) 319 | 320 | case MessageEncryptor.decrypt(token, secret, "") do 321 | {:ok, message} -> decode(message, opts) 322 | :error -> {:error, :invalid} 323 | end 324 | end 325 | 326 | defp decode(message, opts) do 327 | {data, signed, max_age} = 328 | case non_executable_binary_to_term(message) do 329 | {data, signed, max_age} -> {data, signed, max_age} 330 | # For backwards compatibility with Plug.Crypto v1.1 331 | {data, signed} -> {data, signed, 86400} 332 | # For backwards compatibility with Phoenix which had the original code 333 | %{data: data, signed: signed} -> {data, signed, 86400} 334 | end 335 | 336 | case validate_age(signed, Keyword.get(opts, :max_age, max_age)) do 337 | :ok -> {:ok, data} 338 | error -> error 339 | end 340 | end 341 | 342 | ## Helpers 343 | 344 | # Gathers configuration and generates the key secrets and signing secrets. 345 | defp get_secret(secret_key_base, salt, opts) do 346 | iterations = Keyword.get(opts, :key_iterations, 1000) 347 | length = Keyword.get(opts, :key_length, 32) 348 | digest = Keyword.get(opts, :key_digest, :sha256) 349 | cache = Keyword.get(opts, :cache, Plug.Crypto.Keys) 350 | KeyGenerator.generate(secret_key_base, salt, iterations, length, digest, cache) 351 | end 352 | 353 | defp validate_age(_signed, :infinity), do: :ok 354 | defp validate_age(_signed, max_age_secs) when max_age_secs <= 0, do: {:error, :expired} 355 | 356 | defp validate_age(signed, max_age_secs) do 357 | now = now_ms() 358 | 359 | cond do 360 | signed > now -> {:error, :invalid} 361 | signed + trunc(max_age_secs * 1000) < now -> {:error, :expired} 362 | true -> :ok 363 | end 364 | end 365 | 366 | defp now_ms, do: System.os_time(:millisecond) 367 | end 368 | --------------------------------------------------------------------------------