├── .formatter.exs ├── .gitignore ├── .travis.yml ├── ERRORS.md ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib ├── mix │ └── tasks │ │ ├── zachaeus.gen.keys.ex │ │ └── zachaeus.gen.license.ex ├── zachaeus.ex └── zachaeus │ ├── error.ex │ ├── license.ex │ ├── plug.ex │ └── plug │ └── ensure_authenticated.ex ├── mix.exs ├── mix.lock └── test ├── test_helper.exs ├── zachaeus ├── error_test.exs ├── license_test.exs ├── plug │ └── ensure_authenticated_test.exs └── plug_test.exs └── zachaeus_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.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 | zachaeus-*.tar 24 | 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | matrix: 4 | include: 5 | - elixir: 1.8.2 6 | otp_release: 7 | - 20.0 8 | - 21.0 9 | - 22.0 10 | - elixir: 1.9.4 11 | otp_release: 12 | - 20.0 13 | - 21.0 14 | - 22.0 15 | 16 | script: 17 | - mix test 18 | 19 | addons: 20 | apt: 21 | packages: 22 | - libsodium-dev 23 | - gcc 24 | - make 25 | - erlang-dev 26 | -------------------------------------------------------------------------------- /ERRORS.md: -------------------------------------------------------------------------------- 1 | # Errors 2 | 3 | ## Introduction 4 | With Zachaeus you're able to customize the returned error message through general error codes. 5 | If you don't like the default error messages, you can simply match on the error code and return your own messages, suitable for your use case. 6 | 7 | ```elixir 8 | # Sample error 9 | sample_error = %Zachaeus.Error{code: :license_expired, message: "The license has expired"} 10 | 11 | # Match on the error code 'license_expired' 12 | case sample_error do 13 | %Zachaeus.Error{code: :license_expired} -> 14 | {:error, "Hey dude, your license has expired!"} 15 | _any_other_error -> 16 | {:error, "Something unexpected happened"} 17 | end 18 | ``` 19 | 20 | ## Current (default) errors 21 | The following error codes/messages are currently returned by Zachaeus: 22 | 23 | | Code | Message | 24 | |--------------------------|-----------------------------------------------------------------------------------| 25 | | :empty_plan | The given plan cannot be empty | 26 | | :invalid_license_type | Unable to serialize license due to an invalid type | 27 | | :invalid_timestamp_type | Unable to cast timestamp to DateTime | 28 | | :empty_identifier | The given identifier cannot be empty | 29 | | :invalid_license_format | Unable to deserialize license string due to an invalid format | 30 | | :invalid_license_type | Unable to deserialize license due to an invalid type | 31 | | :license_expired | The license has expired | 32 | | :invalid_license_type | The given license is invalid | 33 | | :license_predated | The license is not yet valid | 34 | | :invalid_string_type | Unable to cast data to String | 35 | | :invalid_identifer | The given identifier contains a reserved character | 36 | | :invalid_identifer | The given identifier is not a String | 37 | | :invalid_plan | The given plan contains a reserved character | 38 | | :invalid_plan | The given plan is not a String | 39 | | :invalid_timerange | The given timerange is invalid | 40 | | :invalid_timerange | The the given timerange needs a beginning and an ending DateTime | 41 | | :invalid_timestamp | The timestamp cannot be shifted to UTC timezone | 42 | | :extraction_failed | Unable to extract license from the HTTP Authorization request header | 43 | | :verification_failed | Unable to verify the license to to an unknown error | 44 | | :verification_failed | Unable to verify the license due to an invalid type | 45 | | :validation_failed | Unable to validate license due to an unknown error | 46 | | :validation_failed | Unable to validate license due to an invalid type | 47 | | :unconfigured_secret_key | There is no secret key configured for your application | 48 | | :invalid_secret_key | The given secret key must have a size of 64 bytes | 49 | | :invalid_secret_key | The given secret key has an invalid type | 50 | | :unconfigured_public_key | There is no public key configured for your application | 51 | | :invalid_public_key | The given public key must have a size of 32 bytes | 52 | | :invalid_public_key | The given public key has an invalid type | 53 | | :signature_not_found | Unable to extract the signature from the signed license | 54 | | :license_tampered | The license might be tampered as the signature does not match to the license data | 55 | | :decoding_failed | Unable to decode the configured public key due to an error | 56 | | :public_key_unconfigured | There is no public key configured for your application | 57 | | :decoding_failed | Unable to decode the configured secret key due to an error | 58 | | :secret_key_unconfigured | There is no secret key configured for your application | 59 | | :empty_signed_license | The given signed license cannot be empty | 60 | | :invalid_signed_license | The given signed license has an invalid type | 61 | | :encoding_failed | Unable to encode the given license data | 62 | | :decoding_failed | Unable to decode the given license data | 63 | | :invalid_signed_license | Unable to extract the signature due to an invalid type | 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Matthias Kalb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zachaeus 2 | Zachaeus is a simple and easy to use licensing system for your Elixir application. 3 | It's inspired by JWT, PASETO and other security token systems, which are using asymmetric cryptography. 4 | 5 | A generated Zachaeus license contains all relevant data, which is essential for a simple licensing system. 6 | Because of this nature, Zachaeus can be used without a database and integrates with Plug but can be used outside of it. 7 | If you're implementing something which needs a licensing system, Zachaeus can work for you. 8 | 9 | ## Use cases 10 | - Control access to web endpoints (Plug/Phoenix/) 11 | - Build software, where you want to issue licenses in order to control access and the functional scope 12 | - Restrict access to any kind of software 13 | 14 | ## Features 15 | - Generate public/private key(s) with a mix task 16 | - Generate license(s) with a mix task and the given license data 17 | - Contains an authentication plug which is compatible with web frameworks e.g. Phoenix 18 | - No need to store the private key, used for license generation, on servers outside your organization 19 | - A license contains all relevant data, therefore you don't even need a database 20 | 21 | ## Documentation 22 | API documentation is available at [https://hexdocs.pm/zachaeus](https://hexdocs.pm/zachaeus) 23 | 24 | ## Installation 25 | #### Required libraries 26 | Installation of Zachaeus requires the following libraries to be installed: 27 | - [libsodium](https://download.libsodium.org/doc/) - required for doing the hard work of signing/verifying 28 | 29 | #### Installation steps 30 | The package can be installed as Hex package, just add Zachaeus to your application `mix.exs` 31 | 32 | ```elixir 33 | defp deps do 34 | [{:zachaeus, "~> 1.0.0"}] 35 | end 36 | ``` 37 | 38 | Run `mix deps.get` to fetch and install the package. 39 | 40 | To leverage Zachaeus, you will need to generate a public/secret key pair by running the `mix zachaeus.gen.keys` task. 41 | 42 | After running this mix task, you need to add the generated key pair to your configuration `config/config.exs`. 43 | 44 | ```elixir 45 | config :zachaeus, 46 | public_key: "csKWI0t9mdPoyEWfXj4skhZpjaMp...", 47 | secret_key: "VkmBsZ5oklR8_MGk77AJUxDRpSqJL6449DTgK6y2f-hywpYjS32Z0..." 48 | ``` 49 | 50 | After adding the key pair to your configuration file, you are able to generate license(s) using the `mix zachaeus.gen.license` task. 51 | 52 | ```bash 53 | $ mix zachaeus.gen.license --identifier user_1 --plan default_plan --valid-from 2020-01-01 --valid-until 2020-12-31 54 | ``` 55 | 56 | 🎉🎉 **Congratulations!** 🎉🎉 57 | 58 | You now have a working Zachaeus setup. 59 | 60 | ## Basics 61 | Once Zachaeus was set up correctly, you can issue licenses using the `zachaeus.gen.license` mix task (as shown above) or with your own code. For issuing/signing licenses, Zachaeus requires either the configured secret key in your `config/config.exs` or a directly specified secret key. 62 | 63 | ```elixir 64 | # Define a license with your specific license data 65 | defined_license = %Zachaeus.License{ 66 | identifier: "my_user_id_1", 67 | plan: "default", 68 | valid_from: ~U[2018-11-15 11:00:00Z], 69 | valid_until: ~U[2019-11-30 09:30:00Z] 70 | } 71 | 72 | # Sign the defined license using the configured secret key 73 | signed_license = Zachaeus.sign(defined_license) 74 | 75 | # Verify the signed license using the configured public key 76 | {:ok, verified_license} = Zachaeus.verify(signed_license) 77 | 78 | # Verify the signed license with the configured public key and validate the license in a single step 79 | {:ok, 1123123} = Zachaeus.validate(signed_license) 80 | 81 | # Get a boolean indicator, whether the license could be verified with the configured public key and is valid 82 | Zachaeus.valid?(signed_license) # -> true 83 | ``` 84 | 85 | ## Use with your web framework 86 | Zachaeus comes with a default `EnsureAuthenticated` plug, which can be used with your plug compatible web framework e.g. Phoenix. 87 | 88 | ```elixir 89 | defmodule MyAppWeb.Router do 90 | use Phoenix.Router 91 | 92 | pipeline :api do 93 | plug :accepts, ["json"] 94 | plug Zachaeus.Plug.EnsureAuthenticated 95 | end 96 | 97 | scope "/" do 98 | pipe_through :api 99 | # API related routes... 100 | end 101 | end 102 | ``` 103 | 104 | If you need a custom behaviour, Zachaeus offers the ability to implement a fully customized plug on your own. 105 | Just `use Zachaeus.Plug` in your module and implement the default and the `build_response` callback. 106 | 107 | ```elixir 108 | defmodule CustomAuthentication do 109 | use Zachaeus.Plug 110 | 111 | def init(opts), do: opts 112 | 113 | def call(conn, _opts) do 114 | conn 115 | |> fetch_license() 116 | |> verify_license() 117 | |> validate_license() 118 | |> build_response() 119 | end 120 | 121 | def build_response({conn, {:ok, _license}}), do: conn 122 | def build_response({conn, {:error, %Error{message: message}}}) do 123 | conn 124 | |> put_resp_content_type("text/plain") 125 | |> send_resp(:unauthorized, "Dude, you don't have a valid license!") 126 | |> halt() 127 | end 128 | end 129 | ``` 130 | 131 | ## Configuration 132 | To keep Zachaeus configuration as simple as possible, it only needs a `secret_key` and/or a `public_key` (depending on your setup). 133 | All configuration values may be provided in two ways. 134 | 135 | 1. Through your config file(s) 136 | 2. Passed directly to the function 137 | 138 | If you don't want to store the configuration in the configuration file e.g. juggle with multiple keys or just to use keys stored within a database, all relevant functions like `sign/1`, `verify/1` etc. has a companion where the secret/public key can be specified directly. 139 | 140 | ```elixir 141 | # Using the configured secret_key 142 | signed_license = Zachaeus.sign(license) 143 | 144 | # Using a specific secret_key 145 | custom_secret_key = "thisisyourcustomsecretkey" 146 | signed_license = Zachaeus.sign(license, custom_secrect_key) 147 | ``` 148 | 149 | ### Configuration values 150 | The Zachaeus configuration is really simple, as it just has the following configuration values: 151 | 152 | - `secret_key` - The key which is used to sign a license 153 | - `public_key` - The key which is used to verify a license 154 | 155 | _(The configuration values above are required for Zachaeus to work.)_ 156 | 157 | ### Key security / Split configuration 158 | Due to the nature of asymmetric cryptography, Zachaeus can be set up in a kind of _split configuration_. 159 | With this type of configuration, you can keep your `secret_key` in a controlled and secure environment e.g. on your local computer and you just need to store the `public_key` outside of this secure environment e.g. on your web server. 160 | 161 | When you use this setup, you can generate licenses (using the `secret_key`) from within your secure environment, issue the licenses to your customers and verifying them (using the `public_key`) in an unsecure environment. 162 | 163 | #### License issuing system 164 | It's just required to set the `secret_key` configuration value e.g. in your `config/config.exs`, but it wouldn't hurt either to set the `public_key` configuration value. 165 | 166 | ```elixir 167 | config :zachaeus, 168 | public_key: "csKWI0t9mdPoyEWfXj4skhZpjaMp...", 169 | secret_key: "VkmBsZ5oklR8_MGk77AJUxDRpSqJL6449DTgK6y2f-hywpYjS32Z0..." 170 | ``` 171 | 172 | _► Please keep in mind, that the verification of license only works, when the `public_key` configuration value is set!_ 173 | 174 | #### License verifying system 175 | It's strongly recommended to just set the `public_key` configuration value e.g. in your `config/config.exs` and not to set the `secret_key` configuration value. 176 | 177 | ```elixir 178 | config :zachaeus, 179 | public_key: "csKWI0t9mdPoyEWfXj4skhZpjaMp..." 180 | ``` 181 | 182 | _► I recommend not to set the `secret_key` configuration value in a publicly available environment!_ 183 | 184 | ## Errors 185 | Zachaeus offers the ability to customize the returned error message through general error codes. 186 | 187 | ```elixir 188 | # Sample error 189 | sample_error = %Zachaeus.Error{code: :license_expired, message: "The license has expired"} 190 | 191 | # Match on the error code 'license_expired' 192 | case sample_error do 193 | %Zachaeus.Error{code: :license_expired} -> 194 | {:error, "Hey dude, your license has expired!"} 195 | _any_other_error -> 196 | {:error, "Something unexpected happened"} 197 | end 198 | ``` 199 | Here's the current [list of errors](ERRORS.md) returned by Zachaeus. 200 | 201 | ## License 202 | The MIT License (MIT). Please see [License File](LICENSE) for more information. 203 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Mix.Config 2 | 3 | # Imports the config according to the current environment 4 | import_config "#{Mix.env()}.exs" 5 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Mix.Config 2 | 3 | config :zachaeus, 4 | public_key: "T1bcZJkn67BsDS7_rvpIZx8Yt-d1P7veGDhhGwAtauM", 5 | secret_key: "96wi2LAzY_loFT5fIfiTnFH8Ptnad8NbEKY2Pzlaf2RPVtxkmSfrsGwNLv-u-khnHxi353U_u94YOGEbAC1q4w" 6 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Mix.Config 2 | 3 | config :zachaeus, 4 | public_key: "T1bcZJkn67BsDS7_rvpIZx8Yt-d1P7veGDhhGwAtauM", 5 | secret_key: "96wi2LAzY_loFT5fIfiTnFH8Ptnad8NbEKY2Pzlaf2RPVtxkmSfrsGwNLv-u-khnHxi353U_u94YOGEbAC1q4w" 6 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Mix.Config 2 | 3 | config :zachaeus, 4 | public_key: "T1bcZJkn67BsDS7_rvpIZx8Yt-d1P7veGDhhGwAtauM", 5 | secret_key: "96wi2LAzY_loFT5fIfiTnFH8Ptnad8NbEKY2Pzlaf2RPVtxkmSfrsGwNLv-u-khnHxi353U_u94YOGEbAC1q4w" 6 | -------------------------------------------------------------------------------- /lib/mix/tasks/zachaeus.gen.keys.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Zachaeus.Gen.Keys do 2 | @shortdoc "Generates the public/secret key pair" 3 | 4 | @moduledoc """ 5 | Generates a Zachaeus key pair. 6 | 7 | mix zachaeus.gen.keys 8 | 9 | Generation of a public/secret key pair in order to sign/verify licenses. 10 | The generated key pair can to be stored in your `config.exs` according to the instructions. 11 | 12 | 13 | This task does not require any arguments to work. 14 | """ 15 | use Mix.Task 16 | 17 | @doc false 18 | @impl Mix.Task 19 | def run(_args) do 20 | with {:ok, _salty_started} <- Application.ensure_all_started(:salty), 21 | {:ok, raw_public_key, raw_secret_key} <- Salty.Sign.Ed25519.keypair(), 22 | {:ok, public_key} <- encode_key(raw_public_key), 23 | {:ok, secret_key} <- encode_key(raw_secret_key) 24 | do 25 | Mix.shell().info(""" 26 | Modify your config.exs file and add the following configuration as indicated below: 27 | """) 28 | 29 | Mix.shell().info([ 30 | :green, 31 | """ 32 | config :zachaeus, 33 | public_key: "#{public_key}", 34 | secret_key: "#{secret_key}" 35 | """ 36 | ]) 37 | 38 | Mix.shell().info(""" 39 | If you only want to verify licenses, modify your config.exs file and add the following configuration as indicated below: 40 | """) 41 | 42 | Mix.shell().info([ 43 | :green, 44 | """ 45 | config :zachaeus, 46 | public_key: "#{public_key}" 47 | """ 48 | ]) 49 | 50 | Mix.shell().info(""" 51 | For generating licenses, modify your config.exs file and add the following configuration as indicated below: 52 | """) 53 | 54 | Mix.shell().info([ 55 | :green, 56 | """ 57 | config :zachaeus, 58 | secret_key: "#{secret_key}" 59 | """ 60 | ]) 61 | 62 | Mix.shell().info([ 63 | :red, 64 | """ 65 | HINT: Please make a backup of both keys, otherwise you will lose the ability to verify (already issued) licenses. 66 | """ 67 | ]) 68 | else 69 | {:error, message} -> 70 | Mix.raise(""" 71 | ERROR: #{message} 72 | 73 | Unable to generate public/secret key pair due to the following error: 74 | """) 75 | end 76 | end 77 | 78 | ## -- HELPER FUNCTIONS 79 | @spec encode_key(key :: binary()) :: {:ok, String.t()} | {:error, String.t()} 80 | defp encode_key(key) when is_binary(key) and byte_size(key) > 0, 81 | do: {:ok, Base.url_encode64(key, padding: false)} 82 | 83 | defp encode_key(_invalid_key), 84 | do: {:error, "Unable to encode keys due to invalid data"} 85 | end 86 | -------------------------------------------------------------------------------- /lib/mix/tasks/zachaeus.gen.license.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Zachaeus.Gen.License do 2 | @shortdoc "Generates a license with a configured secret key" 3 | 4 | @moduledoc """ 5 | Generates a Zachaeus license. 6 | 7 | mix zachaeus.gen.license --identifier user_1 --plan default_plan --valid-from 2020-01-01 --valid-until 2020-12-31 8 | 9 | Generation of a license only works when a you have already generated a public/secret key pair 10 | with the `mix zachaeus.gen.keys` generator task and stored at least the secret key in your configuration 11 | according to the instructions. The default time zone in this task is _UTC_, which used for all license dates (valid from and valid until). 12 | 13 | The following named arguments are essential to generate a valid license. 14 | 15 | ## identifier 16 | By default, a license requires an identifier to differentiate users. 17 | To provide an user identifier, 18 | the `--identifier` option needs to be set. For example: 19 | 20 | mix zachaeus.gen.license --identifier user_1 ... 21 | 22 | ## plan 23 | By default, a license requires a plan name which e.g. allows you to enable special features for specific plans. 24 | To provide a plan identifier, 25 | the `--plan` option needs to be set. For example: 26 | 27 | mix zachaeus.gen.license --plan default_plan ... 28 | 29 | ## valid_from 30 | By default, a license requires a timestamp from which the licence is valid. 31 | To provide a starting time, 32 | the `--valid-from` option needs to be set. For example: 33 | 34 | mix zachaeus.gen.license --valid-from 2020-01-01 ... 35 | 36 | The provided date needs to be in ISO8601 format. 37 | This date will be converted to a 'beginning of day' timestamp in the _UTC_ time zone e.g. `2020-01-01T00:00:00Z` 38 | 39 | ## valid_until 40 | By default, a license requires a timestamp to which the licence is valid. 41 | To provide a ending time, 42 | the `--valid-until` option needs to be set. For example: 43 | 44 | mix zachaeus.gen.license --valid-until 2020-12-31 ... 45 | 46 | The provided date needs to be in ISO8601 format. 47 | This date will be converted to a 'end of day' timestamp in the _UTC_ time zone e.g. `2020-12-31T23:59:59Z` 48 | 49 | ## Support with generating the license 50 | This generator requires _ALL_ named options in order to being able generate a license. 51 | If you forget an option or the format of an input is invalid, the generator returns an error message. 52 | """ 53 | alias Zachaeus.{Error, License} 54 | use Mix.Task 55 | 56 | ## -- MODULE CONSTANTS 57 | @switches [identifier: :string, plan: :string, valid_from: :string, valid_until: :string] 58 | 59 | ## -- TASK FUNCTIONS 60 | @doc false 61 | @impl Mix.Task 62 | def run(args) do 63 | with {:ok, _salty_started} <- Application.ensure_all_started(:salty), 64 | {:ok, arguments} <- parse_options(args), 65 | {:ok, license} <- build_license(arguments), 66 | {:ok, signed_license} <- sign_license(license) 67 | do 68 | Mix.shell().info(""" 69 | Used the following data for generating the license: 70 | 71 | Identifier: #{license.identifier} 72 | Plan: #{license.plan} 73 | Valid from: #{DateTime.to_iso8601(license.valid_from)} 74 | Valid until: #{DateTime.to_iso8601(license.valid_until)} 75 | 76 | The generated signed license which can be given e.g. to a customer: 77 | """) 78 | 79 | Mix.shell().info([ 80 | :green, 81 | """ 82 | #{signed_license} 83 | """ 84 | ]) 85 | else 86 | {:error, message} -> 87 | Mix.raise(""" 88 | ERROR: #{message} 89 | 90 | The task expects, for example, the following arguments: 91 | mix zachaeus.gen.license --identifier user_1 --plan default_plan --valid-from 2020-01-01 --valid-until 2020-12-31 92 | """) 93 | end 94 | end 95 | 96 | ## -- HELPER FUNCTIONS 97 | @spec parse_options(arguments :: [binary()]) :: {:ok, Keyword.t()} | {:error, String.t()} 98 | defp parse_options(arguments) do 99 | case OptionParser.parse(arguments, strict: @switches) do 100 | {valid_args, _args, []} when valid_args != [] -> 101 | {:ok, valid_args} 102 | 103 | {_valid_args, _args, invalid_args} when invalid_args != [] -> 104 | {:error, 105 | "Unexpected argument(s) #{ 106 | invalid_args |> Enum.map(fn {arg, _} -> arg end) |> Enum.join(" / ") 107 | }"} 108 | 109 | _unable_to_parse_arguments -> 110 | {:error, "Unable to parse arguments due to an invalid format"} 111 | end 112 | end 113 | 114 | @spec build_license(license_arguments :: Keyword.t()) :: {:ok, License.t()} | {:error, String.t()} 115 | defp build_license( 116 | identifier: identifier, 117 | plan: plan, 118 | valid_from: valid_from, 119 | valid_until: valid_until 120 | ) do 121 | with {:ok, valid_from} <- parse_datetime(valid_from, :valid_from), 122 | {:ok, valid_until} <- parse_datetime(valid_until, :valid_until) 123 | do 124 | {:ok, 125 | %License{ 126 | identifier: identifier, 127 | plan: plan, 128 | valid_from: valid_from, 129 | valid_until: valid_until 130 | } 131 | } 132 | end 133 | end 134 | 135 | defp build_license(_missing_license_data), 136 | do: {:error, "Unable to build license due to missing license data"} 137 | 138 | @spec sign_license(license :: License.t()) :: {:ok, License.signed()} | {:error, String.t()} 139 | defp sign_license(license) do 140 | case Zachaeus.sign(license) do 141 | {:ok, signed_license} -> 142 | {:ok, signed_license} 143 | 144 | {:error, %Error{message: message}} -> 145 | {:error, message} 146 | end 147 | end 148 | 149 | @spec parse_datetime(date :: String.t(), action :: :valid_from | :valid_until) :: {:ok, DateTime.t()} | {:error, String.t()} 150 | defp parse_datetime(date, :valid_from) when is_binary(date) do 151 | with {:ok, date} <- Date.from_iso8601(date) do 152 | {:ok, 153 | %DateTime{ 154 | day: date.day, 155 | month: date.month, 156 | year: date.year, 157 | hour: 0, 158 | minute: 0, 159 | second: 0, 160 | microsecond: {0, 0}, 161 | std_offset: 0, 162 | utc_offset: 0, 163 | zone_abbr: "UTC", 164 | time_zone: "Etc/UTC", 165 | calendar: Calendar.ISO 166 | } 167 | } 168 | else 169 | _unparsable_date -> 170 | {:error, "Unable to parse 'valid_from' date due to an invalid format"} 171 | end 172 | end 173 | defp parse_datetime(date, :valid_until) when is_binary(date) do 174 | with {:ok, date} <- Date.from_iso8601(date) do 175 | {:ok, 176 | %DateTime{ 177 | day: date.day, 178 | month: date.month, 179 | year: date.year, 180 | hour: 23, 181 | minute: 59, 182 | second: 59, 183 | microsecond: {0, 0}, 184 | std_offset: 0, 185 | utc_offset: 0, 186 | zone_abbr: "UTC", 187 | time_zone: "Etc/UTC", 188 | calendar: Calendar.ISO 189 | } 190 | } 191 | else 192 | _unparsable_date -> 193 | {:error, "Unable to parse 'valid_until' date due to an invalid format"} 194 | end 195 | end 196 | defp parse_datetime(_invalid_date, _invalid_action), 197 | do: {:error, "Unable to parse license validity period"} 198 | end 199 | -------------------------------------------------------------------------------- /lib/zachaeus.ex: -------------------------------------------------------------------------------- 1 | defmodule Zachaeus do 2 | @moduledoc """ 3 | Zachaeus is a simple and easy to use licensing system, which uses asymmetric signing to generate and validate licenses. 4 | A generated license contains all relevant data, which is essential for a simple licensing system. 5 | Due to the nature of a zachaeus license, it can be used without a database, if you simply want to verify the validity of a license. 6 | 7 | ## Use cases 8 | - Control access to web endpoints (Plug/Phoenix/) 9 | - Build software, where you want to issue licenses in order to control access and the functional scope 10 | - Restrict access to any kind of software 11 | 12 | ## Technical details 13 | - The license token depends on the (easy to use) asymmetric signing from NaCl 14 | - The license token is encoded with Base64 in an urlsafe format 15 | - The timestamp(s) used within zachaeus are encoded using the UTC timezone 16 | - The license itself is simply encoded as a pipe separated string 17 | 18 | ## Features 19 | - Generate public/private key(s) with a mix task 20 | - Generate license(s) with a mix task and the given license data 21 | - Contains an authentication plug which is compatible with web frameworks e.g. Phoenix 22 | - No need to store the private key, used for license generation, on servers outside your organization 23 | - A license contains all relevant data, therefore you don't even need a database 24 | """ 25 | alias Zachaeus.{Error, License} 26 | alias Salty.Sign.Ed25519 27 | 28 | @doc """ 29 | Signs a license with the configured secret key and returns an urlsafe Base64 encoded license string. 30 | 31 | ## Examples 32 | Zachaeus.sign(%Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-15 11:00:00Z]}) 33 | {:error, %Zachaeus.Error{code: :unconfigured_secret_key, message: "There is no secret key configured for your application"}} 34 | 35 | Zachaeus.sign(%Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-15 11:00:00Z]}) 36 | {:ok, "signed_license..."} 37 | """ 38 | @spec sign(license :: License.t()) :: {:ok, License.signed()} | {:error, Error.t()} 39 | def sign(license) do 40 | with {:ok, secret_key} <- fetch_configured_secret_key(), do: sign(license, secret_key) 41 | end 42 | 43 | @doc """ 44 | Signs a license with the secret key and returns an urlsafe Base64 encoded license string. 45 | 46 | ## Examples 47 | iex> Zachaeus.sign(%Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-15 11:00:00Z]}, "invalid_secret_key") 48 | {:error, %Zachaeus.Error{code: :invalid_secret_key, message: "The given secret key must have a size of 64 bytes"}} 49 | 50 | iex> Zachaeus.sign(%Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-15 11:00:00Z]}, 123123) 51 | {:error, %Zachaeus.Error{code: :invalid_secret_key, message: "The given secret key has an invalid type"}} 52 | 53 | iex> {:ok, _public_key, secret_key} = Salty.Sign.Ed25519.keypair() 54 | iex> Zachaeus.sign("invalid_license_type", secret_key) 55 | {:error, %Zachaeus.Error{code: :invalid_license_type, message: "Unable to serialize license due to an invalid type"}} 56 | 57 | {:ok, _public_key, secret_key} = Salty.Sign.Ed25519.keypair() 58 | Zachaeus.sign(%Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-15 11:00:00Z]}, secret_key) 59 | {:ok, "signed_license..."} 60 | """ 61 | @spec sign(license :: License.t(), secret_key :: binary()) :: {:ok, License.signed()} | {:error, Error.t()} 62 | def sign(license, secret_key) do 63 | with {:ok, serialized_license} <- License.serialize(license), 64 | {:ok, validated_secret_key} <- validate_secret_key(secret_key), 65 | {:ok, license_signature} <- Ed25519.sign_detached(serialized_license, validated_secret_key) 66 | do 67 | encode_signed_license(license_signature <> serialized_license) 68 | end 69 | end 70 | 71 | @doc """ 72 | Verifies a signed license with the configured public key. 73 | 74 | ## Examples 75 | Zachaeus.verify("lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc...") 76 | {:error, %Zachaeus.Error{code: :unconfigured_public_key, message: "There is no public key configured for your application"}} 77 | 78 | Zachaeus.verify("lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc...") 79 | {:ok, %Zachaeus.License{...}} 80 | """ 81 | @spec verify(signed_license :: License.signed()) :: {:ok, License.t()} | {:error, Error.t()} 82 | def verify(signed_license) do 83 | with {:ok, public_key} <- fetch_configured_public_key(), do: verify(signed_license, public_key) 84 | end 85 | 86 | @doc """ 87 | Verifies a given signed license string with a given public key. 88 | 89 | ## Examples 90 | iex> Zachaeus.verify("lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc...", "invalid_public_key") 91 | {:error, %Zachaeus.Error{code: :invalid_public_key, message: "The given public key must have a size of 32 bytes"}} 92 | 93 | iex> Zachaeus.verify("lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc...", 123123) 94 | {:error, %Zachaeus.Error{code: :invalid_public_key, message: "The given public key has an invalid type"}} 95 | 96 | iex> {:ok, public_key, _secret_key} = Salty.Sign.Ed25519.keypair() 97 | iex> Zachaeus.verify("invalid_license", public_key) 98 | {:error, %Zachaeus.Error{code: :signature_not_found, message: "Unable to extract the signature from the signed license"}} 99 | 100 | {:ok, public_key, _secret_key} = Salty.Sign.Ed25519.keypair() 101 | Zachaeus.verify("lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc...", public_key) 102 | {:ok, %Zachaeus.License{...}} 103 | """ 104 | @spec verify(signed_license :: License.signed(), public_key :: binary()) :: {:ok, License.t()} | {:error, Error.t()} 105 | def verify(signed_license, public_key) do 106 | with {:ok, validated_signed_license} <- validate_signed_license(signed_license), 107 | {:ok, validated_public_key} <- validate_public_key(public_key), 108 | {:ok, decoded_signed_license} <- decode_signed_license(validated_signed_license), 109 | {:ok, signature, serialized_license} <- extract_signature(decoded_signed_license) 110 | do 111 | case Ed25519.verify_detached(signature, serialized_license, validated_public_key) do 112 | :ok -> 113 | License.deserialize(serialized_license) 114 | _verification_failed -> 115 | {:error, %Zachaeus.Error{code: :license_tampered, message: "The license might be tampered as the signature does not match to the license data"}} 116 | end 117 | end 118 | end 119 | 120 | @doc """ 121 | Validates a signed license with a configured public key. 122 | 123 | ## Examples 124 | iex> Zachaeus.validate("invalid_license") 125 | {:error, %Zachaeus.Error{code: :signature_not_found, message: "Unable to extract the signature from the signed license"}} 126 | 127 | Zachaeus.validate("QrCTnY52fLzoWquad1ZtYB6EXqjpBRm9dTdGP7cDw2Vl3fuHvZdodW2q0EFNCwvBnY1hxmkrdRDZgHk-NLIEAHVzZXJfMXxkZWZhdWx0X3BsYW58MTU0NjMwMDgwMHw3MjU4MTE4Mzk5") 128 | {:ok, 5680217709} 129 | """ 130 | @spec validate(signed_license :: License.signed()) :: {:ok, Integer.t()} | {:error, Error.t()} 131 | def validate(signed_license) do 132 | with {:ok, public_key} <- fetch_configured_public_key(), do: validate(signed_license, public_key) 133 | end 134 | 135 | @doc """ 136 | Validates a signed license with a given public key. 137 | 138 | ## Examples 139 | iex> Zachaeus.validate("lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc...", "invalid_public_key") 140 | {:error, %Zachaeus.Error{code: :invalid_public_key, message: "The given public key must have a size of 32 bytes"}} 141 | 142 | iex> Zachaeus.validate("lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc...", 123123) 143 | {:error, %Zachaeus.Error{code: :invalid_public_key, message: "The given public key has an invalid type"}} 144 | 145 | iex> Zachaeus.validate("invalid_license", <<79, 86, 220, 100, 153, 39, 235, 176, 108, 13, 46, 255, 174, 250, 72, 103, 31, 24, 183, 231, 117, 63, 187, 222, 24, 56, 97, 27, 0, 45, 106, 227>>) 146 | {:error, %Zachaeus.Error{code: :signature_not_found, message: "Unable to extract the signature from the signed license"}} 147 | 148 | Zachaeus.validate("QrCTnY52fLzoWquad1ZtYB6EXqjpBRm9dTdGP7cDw2Vl3fuHvZdodW2q0EFNCwvBnY1hxmkrdRDZgHk-NLIEAHVzZXJfMXxkZWZhdWx0X3BsYW58MTU0NjMwMDgwMHw3MjU4MTE4Mzk5", <<79, 86, 220, 100, 153, 39, 235, 176, 108, 13, 46, 255, 174, 250, 72, 103, 31, 24, 183, 231, 117, 63, 187, 222, 24, 56, 97, 27, 0, 45, 106, 227>>) 149 | {:ok, 5680217709} 150 | """ 151 | @spec validate(signed_license :: License.signed(), public_key :: String.t()) :: {:ok, Integer.t()} | {:error, Error.t()} 152 | def validate(signed_license, public_key) do 153 | with {:ok, license} <- verify(signed_license, public_key), do: License.validate(license) 154 | end 155 | 156 | 157 | @doc """ 158 | Checks a signed license with the configured public key and indicates whether it is valid using a boolean. 159 | 160 | ## Examples 161 | iex> Zachaeus.valid?("QrCTnY52fLzoWquad1ZtYB6EXqjpBRm9dTdGP7cDw2Vl3fuHvZdodW2q0EFNCwvBnY1hxmkrdRDZgHk-NLIEAHVzZXJfMXxkZWZhdWx0X3BsYW58MTU0NjMwMDgwMHw3MjU4MTE4Mzk3") 162 | false 163 | 164 | iex> Zachaeus.valid?("invalid_license_type") 165 | false 166 | 167 | iex> Zachaeus.valid?("QrCTnY52fLzoWquad1ZtYB6EXqjpBRm9dTdGP7cDw2Vl3fuHvZdodW2q0EFNCwvBnY1hxmkrdRDZgHk-NLIEAHVzZXJfMXxkZWZhdWx0X3BsYW58MTU0NjMwMDgwMHw3MjU4MTE4Mzk5") 168 | true 169 | """ 170 | @spec valid?(signed_license :: License.signed()) :: boolean() 171 | def valid?(signed_license) do 172 | case verify(signed_license) do 173 | {:ok, license} -> License.valid?(license) 174 | _verify_failed -> false 175 | end 176 | end 177 | 178 | @doc """ 179 | Checks a signed license with a given public key and indicates whether it is valid using a boolean. 180 | 181 | ## Examples 182 | iex> Zachaeus.valid?("lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc...", "invalid_public_key") 183 | false 184 | 185 | iex> Zachaeus.valid?("lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc...", 123123) 186 | false 187 | 188 | iex> Zachaeus.valid?("invalid_license", <<79, 86, 220, 100, 153, 39, 235, 176, 108, 13, 46, 255, 174, 250, 72, 103, 31, 24, 183, 231, 117, 63, 187, 222, 24, 56, 97, 27, 0, 45, 106, 227>>) 189 | false 190 | 191 | iex> Zachaeus.valid?("QrCTnY52fLzoWquad1ZtYB6EXqjpBRm9dTdGP7cDw2Vl3fuHvZdodW2q0EFNCwvBnY1hxmkrdRDZgHk-NLIEAHVzZXJfMXxkZWZhdWx0X3BsYW58MTU0NjMwMDgwMHw3MjU4MTE4Mzk5", <<79, 86, 220, 100, 153, 39, 235, 176, 108, 13, 46, 255, 174, 250, 72, 103, 31, 24, 183, 231, 117, 63, 187, 222, 24, 56, 97, 27, 0, 45, 106, 227>>) 192 | true 193 | """ 194 | @spec valid?(signed_license :: License.signed(), public_key :: binary()) :: boolean() 195 | def valid?(signed_license, public_key) do 196 | case verify(signed_license, public_key) do 197 | {:ok, license} -> License.valid?(license) 198 | _verify_failed -> false 199 | end 200 | end 201 | 202 | ## -- SETTINGS HELPER FUNCTIONS 203 | @spec fetch_configured_public_key() :: {:ok, binary()} | {:error, Error.t()} 204 | defp fetch_configured_public_key() do 205 | case Application.fetch_env(:zachaeus, :public_key) do 206 | {:ok, encoded_public_key} when is_binary(encoded_public_key) -> 207 | case Base.url_decode64(encoded_public_key, padding: false) do 208 | {:ok, _public_key} = decoded_public_key -> 209 | decoded_public_key 210 | _error_decoding_public_key -> 211 | {:error, %Error{code: :decoding_failed, message: "Unable to decode the configured public key due to an error"}} 212 | end 213 | {:ok, public_key} -> 214 | {:ok, public_key} 215 | _public_key_not_found -> 216 | {:error, %Error{code: :public_key_unconfigured, message: "There is no public key configured for your application"}} 217 | end 218 | end 219 | 220 | @spec fetch_configured_secret_key() :: {:ok, binary()} | {:error, Error.t()} 221 | defp fetch_configured_secret_key() do 222 | case Application.fetch_env(:zachaeus, :secret_key) do 223 | {:ok, encoded_secret_key} when is_binary(encoded_secret_key) -> 224 | case Base.url_decode64(encoded_secret_key, padding: false) do 225 | {:ok, _secret_key} = decoded_secret_key -> 226 | decoded_secret_key 227 | _error_decoding_secret_key -> 228 | {:error, %Error{code: :decoding_failed, message: "Unable to decode the configured secret key due to an error"}} 229 | end 230 | {:ok, secret_key} -> 231 | {:ok, secret_key} 232 | _secret_key_not_found -> 233 | {:error, %Error{code: :secret_key_unconfigured, message: "There is no secret key configured for your application"}} 234 | end 235 | end 236 | 237 | ## -- VALIDATION HELPER FUNCTIONS 238 | @spec validate_signed_license(signed_license :: License.signed()) :: {:ok, License.signed()} | {:error, Error.t()} 239 | defp validate_signed_license(signed_license) when is_binary(signed_license) and byte_size(signed_license) > 0, 240 | do: {:ok, signed_license} 241 | defp validate_signed_license(signed_license) when is_binary(signed_license) and byte_size(signed_license) <= 0, 242 | do: {:error, %Error{code: :empty_signed_license, message: "The given signed license cannot be empty"}} 243 | defp validate_signed_license(_invalid_signed_license), 244 | do: {:error, %Error{code: :invalid_signed_license, message: "The given signed license has an invalid type"}} 245 | 246 | @spec validate_public_key(public_key :: binary()) :: {:ok, String.t()} | {:error, Error.t()} 247 | defp validate_public_key(public_key) when is_binary(public_key) and byte_size(public_key) == 32, 248 | do: {:ok, public_key} 249 | defp validate_public_key(public_key) when is_binary(public_key) and byte_size(public_key) != 32, 250 | do: {:error, %Error{code: :invalid_public_key, message: "The given public key must have a size of 32 bytes"}} 251 | defp validate_public_key(_invalid_public_key), 252 | do: {:error, %Error{code: :invalid_public_key, message: "The given public key has an invalid type"}} 253 | 254 | @spec validate_secret_key(secret_key :: binary()) :: {:ok, String.t()} | {:error, Error.t()} 255 | defp validate_secret_key(secret_key) when is_binary(secret_key) and byte_size(secret_key) == 64, 256 | do: {:ok, secret_key} 257 | defp validate_secret_key(secret_key) when is_binary(secret_key) and byte_size(secret_key) != 64, 258 | do: {:error, %Error{code: :invalid_secret_key, message: "The given secret key must have a size of 64 bytes"}} 259 | defp validate_secret_key(_invalid_secret_key), 260 | do: {:error, %Error{code: :invalid_secret_key, message: "The given secret key has an invalid type"}} 261 | 262 | ## -- GENERAL HELPER FUNCTIONS 263 | @spec encode_signed_license(license_data :: String.t()) :: {:ok, License.signed()} | {:error, Error.t()} 264 | defp encode_signed_license(license_data) when is_binary(license_data) and byte_size(license_data) > 0, 265 | do: {:ok, Base.url_encode64(license_data, padding: false)} 266 | defp encode_signed_license(_invalid_license_data), 267 | do: {:error, %Error{code: :encoding_failed, message: "Unable to encode the given license data"}} 268 | 269 | @spec decode_signed_license(license_data :: License.signed()) :: {:ok, String.t()} | {:error, Error.t()} 270 | defp decode_signed_license(license_data) when is_binary(license_data) and byte_size(license_data) > 0, 271 | do: Base.url_decode64(license_data, padding: false) 272 | defp decode_signed_license(_invalid_license_data), 273 | do: {:error, %Error{code: :decoding_failed, message: "Unable to decode the given license data"}} 274 | 275 | @spec extract_signature(signed_license :: String.t()) :: {:ok, String.t(), License.serialized()} | {:error, Error.t()} 276 | defp extract_signature(<>), 277 | do: {:ok, signature, serialized_license} 278 | defp extract_signature(signed_license) when is_binary(signed_license), 279 | do: {:error, %Error{code: :signature_not_found, message: "Unable to extract the signature from the signed license"}} 280 | defp extract_signature(_invalid_signed_license), 281 | do: {:error, %Error{code: :invalid_signed_license, message: "Unable to extract the signature due to an invalid type"}} 282 | end 283 | -------------------------------------------------------------------------------- /lib/zachaeus/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Zachaeus.Error do 2 | @moduledoc """ 3 | Represents an error throughout zachaeus. 4 | The error is designed with customizability in mind. 5 | You are able to match on a specific error code and customize the default message. 6 | 7 | ## Example 8 | iex> case %Zachaeus.Error{code: :some_code, message: "Some default message"} do 9 | ...> %Zachaeus.Error{code: :some_code} -> 10 | ...> {:error, "My customized error message"} 11 | ...> end 12 | {:error, "My customized error message"} 13 | """ 14 | defstruct [:code, :message] 15 | 16 | @typedoc """ 17 | The default error used in zachaeus. 18 | It contains a `code`, which can be used as an indicator to be able to customize the default error `message`. 19 | """ 20 | @type t() :: %__MODULE__{code: Atom.t(), message: String.t()} 21 | end 22 | -------------------------------------------------------------------------------- /lib/zachaeus/license.ex: -------------------------------------------------------------------------------- 1 | defmodule Zachaeus.License do 2 | @moduledoc """ 3 | A Zachaeus license contains all relevant data which is essential for a simple licensing system. 4 | Due to the nature of this license, it can be used without a database, if you simply want to verify the validity of a license. 5 | """ 6 | alias Zachaeus.Error 7 | 8 | ## -- MODULE ATTRIBUTES 9 | @default_timezone "Etc/UTC" 10 | @separator_regex ~r/\|/ 11 | 12 | ## -- STRUCT DATA 13 | defstruct identifier: nil, plan: nil, valid_from: DateTime.utc_now(), valid_until: DateTime.utc_now() 14 | 15 | @typedoc """ 16 | The license in the default format. 17 | 18 | ## License data 19 | - `identifier` represents a user or any other entity etc. 20 | - `plan` represents a specifc plan, e.g. to implement a varying behaviour of the application 21 | - `valid_from` represents the beginning of the license 22 | - `valid_until` represents the ending of the license 23 | """ 24 | @type t() :: %__MODULE__{ 25 | identifier: String.t(), 26 | plan: String.t(), 27 | valid_from: DateTime.t(), 28 | valid_until: DateTime.t() 29 | } 30 | 31 | @typedoc """ 32 | The license in a serialized format. 33 | 34 | ## License encoding format 35 | - The license data (identifier, plan, valid_from, valid_until) is separated by a `|` (pipe). 36 | - None of the given license data is allowed to include a `|` (pipe) symbol. 37 | - All timestamps are encoded in unix format within the UTC timezone. 38 | 39 | ## Example 40 | Format: [|||] 41 | Example: "my_user_id_1|default|1542279600|1573815600" 42 | """ 43 | @type serialized() :: String.t() 44 | 45 | @typedoc """ 46 | The serialized, signed and encoded license. 47 | 48 | ## Signed license string 49 | - The license is serialized, signed and an encoded string which contains the license data 50 | - The first 64 byte of the signed license string represents the verification hash 51 | 52 | ## Example 53 | Format: "VGVzdAJxQsXSrgYBkcwiOnWamiattqhhhNN_1jsY-LR_YbsoYpZ18-ogVSxWv7d8DlqzLSz9csqNtSzDk4y0JV5xaAE" 54 | """ 55 | @type signed() :: String.t() 56 | 57 | 58 | ## -- FUNCTIONS 59 | @doc """ 60 | Serializes a license struct into the serialized license string format. 61 | Before serializing, it does a validation of the given license data. 62 | 63 | ## Examples 64 | iex> Zachaeus.License.serialize(%Zachaeus.License{identifier: 1, plan: "default", valid_from: "invalid datetime", valid_until: ~U[2019-11-15 11:00:00Z]}) 65 | {:error, %Zachaeus.Error{code: :invalid_timestamp_type, message: "Unable to cast timestamp to DateTime"}} 66 | 67 | iex> Zachaeus.License.serialize(%Zachaeus.License{identifier: nil, plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-15 11:00:00Z]}) 68 | {:error, %Zachaeus.Error{code: :empty_identifier, message: "The given identifier cannot be empty"}} 69 | 70 | iex> Zachaeus.License.serialize(%Zachaeus.License{identifier: 1, plan: nil, valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-15 11:00:00Z]}) 71 | {:error, %Zachaeus.Error{code: :empty_plan, message: "The given plan cannot be empty"}} 72 | 73 | iex> Zachaeus.License.serialize(%Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-15 11:00:00Z]}) 74 | {:ok, "my_user_id_1|default|1542279600|1573815600"} 75 | """ 76 | @spec serialize(__MODULE__.t()) :: {:ok, __MODULE__.serialized()} | {:error, Zachaeus.Error.t()} 77 | def serialize(%__MODULE__{identifier: raw_identifier, plan: raw_plan, valid_from: raw_valid_from, valid_until: raw_valid_until}) do 78 | with {:ok, casted_identifier} <- cast_string(raw_identifier), 79 | {:ok, casted_plan} <- cast_string(raw_plan), 80 | {:ok, casted_valid_from} <- cast_datetime(raw_valid_from), 81 | {:ok, casted_valid_until} <- cast_datetime(raw_valid_until), 82 | {:ok, identifier} <- validate_identifier(casted_identifier), 83 | {:ok, plan} <- validate_plan(casted_plan), 84 | {:ok, valid_from, valid_until} <- validate_timerange(casted_valid_from, casted_valid_until) do 85 | {:ok, "#{identifier}|#{plan}|#{DateTime.to_unix(valid_from)}|#{DateTime.to_unix(valid_until)}"} 86 | end 87 | end 88 | def serialize(_invalid_license), do: {:error, %Error{code: :invalid_license_type, message: "Unable to serialize license due to an invalid type"}} 89 | 90 | @doc """ 91 | Deserializes a given license string, which was previously serialized, into a license struct. 92 | After deserializing, it does a validation of the given license data. 93 | 94 | ## Examples 95 | iex> Zachaeus.License.deserialize("my_user_id_1|default|invalid datetime|1573815600") 96 | {:error, %Zachaeus.Error{code: :invalid_timestamp_type, message: "Unable to cast timestamp to DateTime"}} 97 | 98 | iex> Zachaeus.License.deserialize(" |default|1542279600|1573815600") 99 | {:error, %Zachaeus.Error{code: :empty_identifier, message: "The given identifier cannot be empty"}} 100 | 101 | iex> Zachaeus.License.deserialize("my_user_id_1| |1542279600|1573815600") 102 | {:error, %Zachaeus.Error{code: :empty_plan, message: "The given plan cannot be empty"}} 103 | 104 | iex> Zachaeus.License.deserialize("absolutely_invalid_license_string") 105 | {:error, %Zachaeus.Error{code: :invalid_license_format, message: "Unable to deserialize license string due to an invalid format"}} 106 | 107 | iex> Zachaeus.License.deserialize("my_user_id_1|default|1542279600|1573815600") 108 | {:ok, %Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-15 11:00:00Z]}} 109 | """ 110 | @spec deserialize(serialized_license :: __MODULE__.serialized()) :: {:ok, __MODULE__.t()} | {:error, Zachaeus.Error.t()} 111 | def deserialize(serialized_license) when is_binary(serialized_license) do 112 | case String.split(serialized_license, @separator_regex, trim: true) do 113 | [identifier_part, plan_part, valid_from_part, valid_until_part] -> 114 | with {:ok, casted_identifier} <- cast_string(identifier_part), 115 | {:ok, casted_plan} <- cast_string(plan_part), 116 | {:ok, casted_valid_from} <- cast_datetime(valid_from_part), 117 | {:ok, casted_valid_until} <- cast_datetime(valid_until_part), 118 | {:ok, identifier} <- validate_identifier(casted_identifier), 119 | {:ok, plan} <- validate_plan(casted_plan), 120 | {:ok, valid_from, valid_until} <- validate_timerange(casted_valid_from, casted_valid_until) do 121 | {:ok, 122 | %__MODULE__{identifier: identifier, plan: plan, valid_from: valid_from, valid_until: valid_until}} 123 | end 124 | 125 | _invalid_serialized_license_format -> 126 | {:error, %Error{code: :invalid_license_format, message: "Unable to deserialize license string due to an invalid format"}} 127 | end 128 | end 129 | def deserialize(_invalid_serialized_license), do: {:error, %Error{code: :invalid_license_type, message: "Unable to deserialize license due to an invalid type"}} 130 | 131 | @doc """ 132 | Validates a license and checks whether it is e.g. predated, expired or generally invalid. 133 | When the license is valid, it returns the remaining license time in seconds. 134 | 135 | ## Examples 136 | iex> Zachaeus.License.validate(%Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-30 09:50:00Z]}) 137 | {:error, %Zachaeus.Error{code: :license_expired, message: "The license has expired"}} 138 | 139 | iex> Zachaeus.License.validate(%{}) 140 | {:error, %Zachaeus.Error{code: :invalid_license_type, message: "The given license is invalid"}} 141 | 142 | Zachaeus.License.validate(%Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2099-11-30 09:50:00Z]}) 143 | {:ok, 12872893} 144 | """ 145 | @spec validate(__MODULE__.t()) :: {:ok, Integer.t()} | {:error, Zachaeus.Error.t()} 146 | def validate(%__MODULE__{valid_from: valid_from, valid_until: valid_until}) do 147 | with {:ok, valid_from} <- shift_datetime(valid_from), 148 | {:ok, valid_until} <- shift_datetime(valid_until), 149 | {:ok, validation_datetime} <- shift_datetime(DateTime.utc_now()) 150 | do 151 | case DateTime.compare(valid_from, validation_datetime) do 152 | from_timerange when from_timerange in [:eq, :lt] -> 153 | case DateTime.compare(valid_until, validation_datetime) do 154 | until_timerange when until_timerange in [:eq, :gt] -> 155 | {:ok, DateTime.diff(valid_until, validation_datetime)} 156 | _outdated_license -> 157 | {:error, %Error{code: :license_expired, message: "The license has expired"}} 158 | end 159 | _predated_license -> 160 | {:error, %Error{code: :license_predated, message: "The license is not yet valid"}} 161 | end 162 | end 163 | end 164 | def validate(_invalid_license), do: {:error, %Error{code: :invalid_license_type, message: "The given license is invalid"}} 165 | 166 | @doc """ 167 | Validates a license and checks whether it is e.g. predated, expired or generally invalid and indicates that with a boolean. 168 | 169 | ## Examples 170 | iex> Zachaeus.License.valid?(%Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2019-11-30 09:50:00Z]}) 171 | false 172 | 173 | iex> Zachaeus.License.valid?(%{}) 174 | false 175 | 176 | iex> Zachaeus.License.valid?(%Zachaeus.License{identifier: "my_user_id_1", plan: "default", valid_from: ~U[2018-11-15 11:00:00Z], valid_until: ~U[2099-11-30 09:50:00Z]}) 177 | true 178 | """ 179 | @spec valid?(__MODULE__.t()) :: boolean() 180 | def valid?(%__MODULE__{} = license) do 181 | case validate(license) do 182 | {:ok, _remaining_time} -> true 183 | _invalid_license -> false 184 | end 185 | end 186 | def valid?(_invalid_license), do: false 187 | 188 | ## -- CAST HELPER FUNCTIONS 189 | @spec cast_string(data :: String.t() | Integer.t() | Float.t() | Atom.t() | nil) :: {:ok, String.t()} | {:error, Zachaeus.Error.t()} 190 | defp cast_string(data) when is_binary(data), do: {:ok, String.trim(data)} 191 | defp cast_string(data) when is_number(data) or is_atom(data) or is_nil(data) do 192 | data 193 | |> to_string() 194 | |> cast_string() 195 | end 196 | defp cast_string(_invalid_data), do: {:error, %Error{code: :invalid_string_type, message: "Unable to cast data to String"}} 197 | 198 | @spec cast_datetime(timestamp :: DateTime.t() | Integer.t() | Float.t() | String.t()) :: {:ok, DateTime.t()} | {:error, Zachaeus.Error.t()} 199 | defp cast_datetime(%DateTime{} = timestamp), do: shift_datetime(timestamp) 200 | defp cast_datetime(timestamp) when is_integer(timestamp) do 201 | case DateTime.from_unix(timestamp) do 202 | {:ok, %DateTime{} = timestamp} -> 203 | shift_datetime(timestamp) 204 | 205 | _unable_to_cast_to_datetime -> 206 | {:error, %Error{code: :invalid_timestamp_type, message: "Unable to cast timestamp to DateTime"}} 207 | end 208 | end 209 | defp cast_datetime(timestamp) when is_float(timestamp) do 210 | timestamp 211 | |> Kernel.trunc() 212 | |> cast_datetime() 213 | end 214 | defp cast_datetime(timestamp) when is_binary(timestamp) do 215 | case Integer.parse(timestamp) do 216 | {timestamp, _unparsable_data} -> 217 | cast_datetime(timestamp) 218 | 219 | _unable_to_cast_to_integer -> 220 | {:error, %Error{code: :invalid_timestamp_type, message: "Unable to cast timestamp to DateTime"}} 221 | end 222 | end 223 | defp cast_datetime(_invalid_timestamp), do: {:error, %Error{code: :invalid_timestamp_type, message: "Unable to cast timestamp to DateTime"}} 224 | 225 | ## -- VALIDATION HELPER FUNCTIONS 226 | @spec validate_identifier(identifier :: String.t()) :: {:ok, String.t()} | {:error, Zachaeus.Error.t()} 227 | defp validate_identifier(identifier) when is_binary(identifier) do 228 | cond do 229 | identifier |> String.trim() |> String.length() <= 0 -> 230 | {:error, %Error{code: :empty_identifier, message: "The given identifier cannot be empty"}} 231 | 232 | Regex.match?(@separator_regex, identifier) -> 233 | {:error, %Error{code: :invalid_identifer, message: "The given identifier contains a reserved character"}} 234 | 235 | true -> 236 | {:ok, identifier} 237 | end 238 | end 239 | defp validate_identifier(_invalid_identifier), do: {:error, %Error{code: :invalid_identifer, message: "The given identifier is not a String"}} 240 | 241 | @spec validate_plan(plan :: String.t()) :: {:ok, String.t()} | {:error, Zachaeus.Error.t()} 242 | defp validate_plan(plan) when is_binary(plan) do 243 | cond do 244 | plan |> String.trim() |> String.length() <= 0 -> 245 | {:error, %Error{code: :empty_plan, message: "The given plan cannot be empty"}} 246 | 247 | Regex.match?(@separator_regex, plan) -> 248 | {:error, %Error{code: :invalid_plan, message: "The given plan contains a reserved character"}} 249 | 250 | true -> 251 | {:ok, plan} 252 | end 253 | end 254 | defp validate_plan(_invalid_plan), do: {:error, %Error{code: :invalid_plan, message: "The given plan is not a String"}} 255 | 256 | @spec validate_timerange(DateTime.t(), DateTime.t()) :: {:ok, DateTime.t(), DateTime.t()} | {:error, Zachaeus.Error.t()} 257 | defp validate_timerange(%DateTime{} = valid_from, %DateTime{} = valid_until) do 258 | with {:ok, valid_from} <- shift_datetime(valid_from), {:ok, valid_until} <- shift_datetime(valid_until) do 259 | case DateTime.compare(valid_from, valid_until) do 260 | timerange when timerange in [:eg, :lt] -> 261 | {:ok, valid_from, valid_until} 262 | _invalid_timerange -> 263 | {:error, %Error{code: :invalid_timerange, message: "The given timerange is invalid"}} 264 | end 265 | end 266 | end 267 | defp validate_timerange(_invalid_valid_from, _invalid_valid_until), do: {:error, %Error{code: :invalid_timerange, message: "The the given timerange needs a beginning and an ending DateTime"}} 268 | 269 | ## -- GENERAL HELPER FUNCTIONS 270 | @spec shift_datetime(timestamp :: DateTime.t()) :: {:ok, DateTime.t()} | {:error, Zachaeus.Error.t()} 271 | defp shift_datetime(%DateTime{} = timestamp), do: DateTime.shift_zone(timestamp, @default_timezone) 272 | defp shift_datetime(_invalid_timestamp), do: {:error, %Error{code: :invalid_timestamp, message: "The timestamp cannot be shifted to UTC timezone"}} 273 | end 274 | -------------------------------------------------------------------------------- /lib/zachaeus/plug.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule Zachaeus.Plug do 3 | @moduledoc """ 4 | Provides functions and a behaviour for dealing with Zachaeus in a Plug environment. 5 | You can use the following functions to build plugs with your own behaviour. 6 | To fulfill the behaviour, the `build_response` callback needs to be implemented within your custom plug. 7 | 8 | The usual functions you would use in your plug are: 9 | 10 | ### `fetch_license(conn)` 11 | Try to get a signed license passed from the HTTP authorization request header. 12 | When an error occurs, the error is forwarded, in order to be handled within the `build_response` function. 13 | 14 | ```elixir 15 | {:ok, "lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc..."} = Zachaeus.Plug.fetch_license(conn) 16 | ``` 17 | 18 | ### `verify_license({conn, signed_license})` 19 | Verifies a signed license with the `public_key` stored in your configuration environment. 20 | When an error occurs, the error is forwarded, in order to be handled within the `build_response` function. 21 | 22 | ```elixir 23 | {conn, {:ok, %License{}}} = Zachaeus.Plug.verify_license({conn, {:ok, "lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc..."}}) 24 | ``` 25 | 26 | ### `validate_license({conn, license})` 27 | Validates an already verified license whether it is still valid. 28 | When an error occurs, the error is forwarded, in order to be handled within the `build_response` function. 29 | 30 | ```elixir 31 | {conn, {:ok, %License{...}}} = Zachaeus.Plug.validate_license({conn, {:ok, %License{...}}}) 32 | ``` 33 | """ 34 | alias Zachaeus.{License, Error} 35 | import Plug.Conn 36 | 37 | ## -- PLUG MACRO 38 | defmacro __using__(_opts) do 39 | quote do 40 | alias Zachaeus.{License, Error} 41 | import Zachaeus.Plug 42 | import Plug.Conn 43 | 44 | @behaviour Plug 45 | @behaviour Zachaeus.Plug 46 | end 47 | end 48 | 49 | ## -- PLUG BEHAVIOUR 50 | @doc """ 51 | Respond whether the license is still valid or has already expired. 52 | This callback is meant to implement your own logic, e.g. rendering a template, returning some JSON or just aplain text. 53 | 54 | ## Example 55 | conn = Zachaeus.Plug.build_response({conn, {:ok, %License{...}}}) 56 | """ 57 | @callback build_response({Plug.Conn.t(), {:ok, License.t()} | {:error, Error.t()}}) :: Plug.Conn.t() 58 | 59 | ## -- PLUG FUNCTIONS 60 | @doc """ 61 | Fetches a signed license which is passed via the `Authorization` HTTP request header as a Bearer Token. 62 | When no valid signed license is found, the function returns a corresponding error. 63 | 64 | ## HTTP header example 65 | Authorization: lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc... 66 | 67 | ## Example 68 | {conn, {:ok, "lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc..."}} = Zachaeus.Plug.fetch_license(conn) 69 | """ 70 | @spec fetch_license(Plug.Conn.t()) :: {Plug.Conn.t(), {:ok, License.signed()} | {:error, Error.t()}} 71 | def fetch_license(conn) do 72 | case get_req_header(conn, "authorization") do 73 | ["Bearer " <> signed_license | _] when is_binary(signed_license) and byte_size(signed_license) > 0 -> 74 | {conn, {:ok, signed_license}} 75 | _license_not_found_in_request -> 76 | {conn, {:error, %Error{code: :extraction_failed, message: "Unable to extract license from the HTTP Authorization request header"}}} 77 | end 78 | end 79 | 80 | @doc """ 81 | Verifies that a signed license is valid and has not been tampered. 82 | When no signed license could be retrieved by the `fetch_license` function, it forwards this error. 83 | 84 | ## Example 85 | {conn, {:ok, %License{}}} = Zachaeus.Plug.verify_license({conn, {:ok, "lzcAxWfls4hDHs8fHwJu53AWsxX08KYpxGUwq4qsc..."}}) 86 | """ 87 | @spec verify_license({Plug.Conn.t(), {:ok, License.signed()} | {:error, Error.t()}}) :: {Plug.Conn.t(), {:ok, License.t()} | {:error, Error.t()}} 88 | def verify_license({conn, {:ok, signed_license}}) when is_binary(signed_license) and byte_size(signed_license) > 0 do 89 | case Zachaeus.verify(signed_license) do 90 | {:ok, %License{identifier: identifier, plan: plan}} = result -> 91 | conn = 92 | conn 93 | |> put_private(:zachaeus_identifier, identifier) 94 | |> put_private(:zachaeus_plan, plan) 95 | 96 | {conn, result} 97 | {:error, %Error{}} = error -> 98 | {conn, error} 99 | _unknown_error -> 100 | {conn, {:error, %Error{code: :verification_failed, message: "Unable to verify the license to to an unknown error"}}} 101 | end 102 | end 103 | def verify_license({conn, {:error, %Error{}} = error}), 104 | do: {conn, error} 105 | def verify_license({conn, _invalid_signed_license_or_error}), 106 | do: {conn, {:error, %Error{code: :verification_failed, message: "Unable to verify the license due to an invalid type"}}} 107 | 108 | @doc """ 109 | Validates a license whether it has not expired. 110 | When the license could not be verified by `verify_license` it forwards this error. 111 | 112 | ## Example 113 | {conn, {:ok, %License{...}} = Zachaeus.Plug.validate_license({conn, {:ok, %License{...}}}) 114 | """ 115 | @spec validate_license({Plug.Conn.t(), {:ok, License.t()} | {:error, Error.t()}}) :: {Plug.Conn.t(), {:ok, License.t()} | {:error, Error.t()}} 116 | def validate_license({conn, {:ok, %License{} = license} = result}) do 117 | case License.validate(license) do 118 | {:ok, remaining_seconds} -> 119 | conn = conn 120 | |> put_private(:zachaeus_remaining_seconds, remaining_seconds) 121 | 122 | {conn, result} 123 | {:error, %Error{}} = error -> 124 | {conn, error} 125 | _unknown_error -> 126 | {conn, {:error, %Error{code: :validation_failed, message: "Unable to validate license due to an unknown error"}}} 127 | end 128 | end 129 | def validate_license({conn, {:error, %Error{}} = error}), 130 | do: {conn, error} 131 | def validate_license({conn, _invalid_license_or_error}), 132 | do: {conn, {:error, %Error{code: :validation_failed, message: "Unable to validate license due to an invalid type"}}} 133 | 134 | ## -- PLUG INFORMATION FUNCTIONS 135 | @doc """ 136 | Get the identifier assigned with the license. 137 | 138 | ## Example 139 | "user_1" = zachaeus_identifier(conn) 140 | """ 141 | @spec zachaeus_identifier(Plug.Conn.t()) :: String.t() | nil 142 | def zachaeus_identifier(conn), do: conn.private[:zachaeus_identifier] 143 | 144 | @doc """ 145 | Get the plan assigned with the license. 146 | 147 | ## Example 148 | "standard_plan" = zachaeus_plan(conn) 149 | """ 150 | @spec zachaeus_plan(Plug.Conn.t()) :: String.t() | nil 151 | def zachaeus_plan(conn), do: conn.private[:zachaeus_plan] 152 | 153 | @doc """ 154 | Get the remaining seconds of the license. 155 | 156 | ## Example 157 | 17436373 = zachaeus_remaining_seconds(conn) 158 | """ 159 | @spec zachaeus_remaining_seconds(Plug.Conn.t()) :: Integer.t() | nil 160 | def zachaeus_remaining_seconds(conn), do: conn.private[:zachaeus_remaining_seconds] 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/zachaeus/plug/ensure_authenticated.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Plug) do 2 | defmodule Zachaeus.Plug.EnsureAuthenticated do 3 | @moduledoc """ 4 | This plug ensures that a valid and unexpired zachaeus license was provided for this request. 5 | If the license could not be found, has expired or is just invalid, it returns a JSON error representation. 6 | You can use this plug with your application e.g. within a phoenix authentication pipeline. 7 | 8 | For example: 9 | defmodule MyAppWeb.Router do 10 | use Phoenix.Router 11 | 12 | pipeline :api do 13 | plug :accepts, ["json"] 14 | plug Zachaeus.Plug.EnsureAuthenticated 15 | end 16 | 17 | scope "/" do 18 | pipe_through :api 19 | # API related routes... 20 | end 21 | end 22 | """ 23 | use Zachaeus.Plug 24 | 25 | def init(opts), do: opts 26 | 27 | def call(conn, _opts) do 28 | conn 29 | |> fetch_license() 30 | |> verify_license() 31 | |> validate_license() 32 | |> build_response() 33 | end 34 | 35 | def build_response({conn, {:ok, _license}}), do: conn 36 | def build_response({conn, {:error, %Error{message: message}}}) do 37 | conn 38 | |> put_resp_content_type("application/json") 39 | |> send_resp(:unauthorized, Jason.encode!(%{error: message})) 40 | |> halt() 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Zachaeus.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/railsmechanic/zachaeus" 5 | @maintainers ["Matthias Kalb"] 6 | 7 | def project do 8 | [ 9 | name: "Zachaeus", 10 | app: :zachaeus, 11 | version: "1.0.0", 12 | elixir: "~> 1.8", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | description: description(), 16 | package: package(), 17 | maintainers: @maintainers, 18 | source_url: @source_url 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | extra_applications: [:logger] 25 | ] 26 | end 27 | 28 | defp deps do 29 | [ 30 | {:jason, "~> 1.1"}, 31 | {:plug, "~> 1.8", optional: true}, 32 | {:salty, "~> 0.1.3", hex: :libsalty}, 33 | {:ex_doc, "~> 0.21.2", only: [:dev], runtime: false}, 34 | ] 35 | end 36 | 37 | defp description do 38 | "Zachaeus is an easy to use licensing system, which uses asymmetric signing to generate and validate licenses." 39 | end 40 | 41 | defp package do 42 | [ 43 | name: "zachaeus", 44 | licenses: ["MIT"], 45 | maintainers: @maintainers, 46 | links: %{"GitHub" => @source_url} 47 | ] 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark": {:hex, :earmark, "1.4.2", "3aa0bd23bc4c61cf2f1e5d752d1bb470560a6f8539974f767a38923bb20e1d7f", [:mix], [], "hexpm"}, 3 | "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm"}, 4 | "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, 5 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 6 | "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, 8 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, 10 | "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, 11 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, 12 | "salty": {:hex, :libsalty, "0.1.3", "13332eb13ac995f5deb76903b44f96f740e1e3a6e511222bffdd8b42cd079ffb", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, 13 | } 14 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:plug) 2 | Application.ensure_all_started(:salty) 3 | ExUnit.start() 4 | -------------------------------------------------------------------------------- /test/zachaeus/error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Zachaeus.ErrorTest do 2 | use ExUnit.Case, async: true 3 | doctest Zachaeus.Error 4 | 5 | test "for required fields" do 6 | error = %Zachaeus.Error{} 7 | assert Map.has_key?(error, :code) 8 | assert Map.has_key?(error, :message) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/zachaeus/license_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Zachaeus.LicenseTest do 2 | use ExUnit.Case, async: true 3 | doctest Zachaeus.License 4 | 5 | alias Zachaeus.{Error, License} 6 | 7 | setup_all do 8 | {:ok, %{ 9 | license: %License{ 10 | identifier: "user_1", 11 | plan: "default_plan", 12 | valid_from: ~U[2019-01-01 00:00:00Z], 13 | valid_until: ~U[2199-12-31 23:59:59Z] 14 | } 15 | }} 16 | end 17 | 18 | describe "serialize/1" do 19 | test "with invalid identifier", context do 20 | # -> nil 21 | license = %{context.license | identifier: nil} 22 | assert {:error, %Error{code: :empty_identifier}} = License.serialize(license) 23 | 24 | # -> empty string 25 | license = %{context.license | identifier: ""} 26 | assert {:error, %Error{code: :empty_identifier}} = License.serialize(license) 27 | 28 | # -> invalid type 29 | license = %{context.license | identifier: %{}} 30 | assert {:error, %Error{code: :invalid_string_type}} = License.serialize(license) 31 | 32 | # -> reserved character 33 | license = %{context.license | identifier: "|"} 34 | assert {:error, %Error{code: :invalid_identifer}} = License.serialize(license) 35 | end 36 | 37 | test "with invalid plan", context do 38 | # -> nil 39 | license = %{context.license | plan: nil} 40 | assert {:error, %Error{code: :empty_plan}} = License.serialize(license) 41 | 42 | # -> empty string 43 | license = %{context.license | plan: ""} 44 | assert {:error, %Error{code: :empty_plan}} = License.serialize(license) 45 | 46 | # -> invalid type 47 | license = %{context.license | plan: %{}} 48 | assert {:error, %Error{code: :invalid_string_type}} = License.serialize(license) 49 | 50 | # -> reserved character 51 | license = %{context.license | plan: "|"} 52 | assert {:error, %Error{code: :invalid_plan}} = License.serialize(license) 53 | end 54 | 55 | test "with invalid valid_from", context do 56 | # -> nil 57 | license = %{context.license | valid_from: nil} 58 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.serialize(license) 59 | 60 | # -> empty string 61 | license = %{context.license | valid_from: ""} 62 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.serialize(license) 63 | 64 | # -> invalid type 65 | license = %{context.license | valid_from: %{}} 66 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.serialize(license) 67 | 68 | # -> reserved character 69 | license = %{context.license | valid_from: "|"} 70 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.serialize(license) 71 | end 72 | 73 | test "with invalid valid_until", context do 74 | # -> nil 75 | license = %{context.license | valid_until: nil} 76 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.serialize(license) 77 | 78 | # -> empty string 79 | license = %{context.license | valid_until: ""} 80 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.serialize(license) 81 | 82 | # -> invalid type 83 | license = %{context.license | valid_until: %{}} 84 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.serialize(license) 85 | 86 | # -> reserved character 87 | license = %{context.license | valid_until: "|"} 88 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.serialize(license) 89 | end 90 | 91 | test "with invalid time range", context do 92 | license = %{context.license | valid_from: context.license.valid_until, valid_until: context.license.valid_from} 93 | assert {:error, %Error{code: :invalid_timerange}} = License.serialize(license) 94 | end 95 | 96 | test "with invalid license type" do 97 | assert {:error, %Error{code: :invalid_license_type}} = License.serialize(%{}) 98 | end 99 | 100 | test "with valid data", context do 101 | assert {:ok, serialized_license} = License.serialize(context.license) 102 | assert "user_1|default_plan|1546300800|7258118399" = serialized_license 103 | end 104 | end 105 | 106 | describe "deserialize/1" do 107 | test "with invalid identifier" do 108 | # -> nil 109 | serialized_license = "|default_plan|1546300800|7258118399" 110 | assert {:error, %Error{code: :invalid_license_format,}} = License.deserialize(serialized_license) 111 | 112 | # -> empty string 113 | serialized_license = " |default_plan|1546300800|7258118399" 114 | assert {:error, %Error{code: :empty_identifier,}} = License.deserialize(serialized_license) 115 | end 116 | 117 | test "with invalid plan" do 118 | # -> nil 119 | serialized_license = "user_1||1546300800|7258118399" 120 | assert {:error, %Error{code: :invalid_license_format,}} = License.deserialize(serialized_license) 121 | 122 | # -> empty string 123 | serialized_license = "user_1| |1546300800|7258118399" 124 | assert {:error, %Error{code: :empty_plan,}} = License.deserialize(serialized_license) 125 | end 126 | 127 | test "with invalid valid_from" do 128 | # -> nil 129 | serialized_license = "user_1|default_plan||7258118399" 130 | assert {:error, %Error{code: :invalid_license_format}} = License.deserialize(serialized_license) 131 | 132 | # -> empty string 133 | serialized_license = "user_1|default_plan| |7258118399" 134 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.deserialize(serialized_license) 135 | 136 | # -> invalid type 137 | serialized_license = "user_1|default_plan|invalid|7258118399" 138 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.deserialize(serialized_license) 139 | end 140 | 141 | test "with invalid valid_until" do 142 | # -> nil 143 | serialized_license = "user_1|default_plan|1546300800|" 144 | assert {:error, %Error{code: :invalid_license_format}} = License.deserialize(serialized_license) 145 | 146 | # -> empty string 147 | serialized_license = "user_1|default_plan|1546300800| " 148 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.deserialize(serialized_license) 149 | 150 | # -> invalid type 151 | serialized_license = "user_1|default_plan|1546300800|invalid" 152 | assert {:error, %Error{code: :invalid_timestamp_type}} = License.deserialize(serialized_license) 153 | end 154 | 155 | test "with invalid time range" do 156 | serialized_license = "user_1|default_plan|7258118399|1546300800" 157 | assert {:error, %Error{code: :invalid_timerange}} = License.deserialize(serialized_license) 158 | end 159 | 160 | test "with invalid serialized license format" do 161 | serialized_license = "user_1|default_plan|1546300800|7258118399|some|additional|data" 162 | assert {:error, %Error{code: :invalid_license_format}} = License.deserialize(serialized_license) 163 | 164 | serialized_license = "some|additional|data|user_1|default_plan|1546300800|7258118399" 165 | assert {:error, %Error{code: :invalid_license_format}} = License.deserialize(serialized_license) 166 | 167 | serialized_license = "absolutely_invalid_license_format" 168 | assert {:error, %Error{code: :invalid_license_format}} = License.deserialize(serialized_license) 169 | end 170 | 171 | test "with invalid serialized license type range" do 172 | assert {:error, %Error{code: :invalid_license_type}} = License.deserialize(%{}) 173 | end 174 | 175 | test "with valid data", context do 176 | # - integer timestamp 177 | serialized_license = "user_1|default_plan|1546300800|7258118399" 178 | assert {:ok, license} = License.deserialize(serialized_license) 179 | assert license = context.license 180 | 181 | # - float timestamp 182 | serialized_license = "user_1|default_plan|1546300800.0|7258118399.0" 183 | assert {:ok, license} = License.deserialize(serialized_license) 184 | assert license = context.license 185 | end 186 | end 187 | 188 | describe "validate/1" do 189 | test "with a predated license", context do 190 | license = %{context.license | valid_from: ~U[2199-01-01 00:00:00Z]} 191 | assert {:error, %Error{code: :license_predated}} = License.validate(license) 192 | end 193 | 194 | test "with an outdated license", context do 195 | license = %{context.license | valid_until: ~U[2019-01-01 00:00:00Z]} 196 | assert {:error, %Error{code: :license_expired}} = License.validate(license) 197 | end 198 | 199 | test "with invalid license type" do 200 | assert {:error, %Error{code: :invalid_license_type}} = License.validate(%{}) 201 | end 202 | 203 | test "with valid data", context do 204 | assert {:ok, remaining_seconds} = License.validate(context.license) 205 | assert is_integer(remaining_seconds) 206 | assert remaining_seconds > 0 207 | end 208 | end 209 | 210 | describe "valid?/1" do 211 | test "with a predated license", context do 212 | license = %{context.license | valid_from: ~U[2199-01-01 00:00:00Z]} 213 | refute License.valid?(license) 214 | end 215 | 216 | test "with an outdated license", context do 217 | license = %{context.license | valid_until: ~U[2019-01-01 00:00:00Z]} 218 | refute License.valid?(license) 219 | end 220 | 221 | test "with invalid license type" do 222 | refute License.valid?(%{}) 223 | end 224 | 225 | test "with valid data", context do 226 | assert License.valid?(context.license) 227 | end 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /test/zachaeus/plug/ensure_authenticated_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Zachaeus.Plug.EnsureAuthenticatedTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias Zachaeus.License 6 | alias Zachaeus.Plug.EnsureAuthenticated 7 | 8 | setup_all do 9 | {:ok, %{ 10 | predated_license: %License{ 11 | identifier: "user_1", 12 | plan: "default_plan", 13 | valid_from: ~U[2199-01-01 00:00:00Z], 14 | valid_until: ~U[2199-12-31 23:59:59Z] 15 | }, 16 | expired_license: %License{ 17 | identifier: "user_1", 18 | plan: "default_plan", 19 | valid_from: ~U[2018-01-01 00:00:00Z], 20 | valid_until: ~U[2018-12-31 23:59:59Z] 21 | }, 22 | }} 23 | end 24 | 25 | setup do 26 | {:ok, %{conn: conn(:post, "/a/fancy/api/call")}} 27 | end 28 | 29 | describe "errors on" do 30 | test "missing authorization header", context do 31 | conn = 32 | context.conn 33 | |> EnsureAuthenticated.call([]) 34 | 35 | assert Jason.decode!(conn.resp_body) == %{"error" => "Unable to extract license from the HTTP Authorization request header"} 36 | assert conn.status == 401 37 | assert conn.halted 38 | end 39 | 40 | test "empty authorization header", context do 41 | conn = 42 | context.conn 43 | |> put_req_header("authorization", "") 44 | |> EnsureAuthenticated.call([]) 45 | 46 | assert Jason.decode!(conn.resp_body) == %{"error" => "Unable to extract license from the HTTP Authorization request header"} 47 | assert conn.status == 401 48 | assert conn.halted 49 | end 50 | 51 | test "predated license", context do 52 | assert {:ok, signed_license} = Zachaeus.sign(context.predated_license) 53 | conn = 54 | context.conn 55 | |> put_req_header("authorization", "Bearer #{signed_license}") 56 | |> EnsureAuthenticated.call([]) 57 | 58 | assert Jason.decode!(conn.resp_body) == %{"error" => "The license is not yet valid"} 59 | assert conn.status == 401 60 | assert conn.halted 61 | end 62 | 63 | test "expired license", context do 64 | assert {:ok, signed_license} = Zachaeus.sign(context.expired_license) 65 | conn = 66 | context.conn 67 | |> put_req_header("authorization", "Bearer #{signed_license}") 68 | |> EnsureAuthenticated.call([]) 69 | 70 | assert Jason.decode!(conn.resp_body) == %{"error" => "The license has expired"} 71 | assert conn.status == 401 72 | assert conn.halted 73 | end 74 | end 75 | 76 | describe "passes on" do 77 | test "valid license", context do 78 | assert {:ok, signed_license} = 79 | Zachaeus.sign(%License{ 80 | identifier: "user_1", 81 | plan: "default_plan", 82 | valid_from: ~U[2019-01-01 00:00:00Z], 83 | valid_until: ~U[2199-12-31 23:59:59Z] 84 | }) 85 | 86 | conn = 87 | context.conn 88 | |> put_req_header("authorization", "Bearer #{signed_license}") 89 | |> EnsureAuthenticated.call([]) 90 | 91 | refute conn.halted 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/zachaeus/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Zachaeus.PlugTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | doctest Zachaeus.Plug 5 | 6 | alias Zachaeus.{Error, License} 7 | 8 | setup_all do 9 | {:ok, %{ 10 | tampered_signed_license: "QrCTnY52fLzoWquad1ZtYB6EXqjpBRm9dTdGP7cDw2Vl3fuHvZdodW2q0EFNCwvBnY1hxmkrdRDZgHk-NLIEAHVzZXJfMXxkZWZhdWx0X3BsYW58MTU0NjMwMDgwMHw3MjU4MTE4Mzk4", 11 | expired_license: %License{ 12 | identifier: "user_1", 13 | plan: "default_plan", 14 | valid_from: ~U[2018-01-01 00:00:00Z], 15 | valid_until: ~U[2018-12-31 23:59:59Z] 16 | }, 17 | valid_license: %License{ 18 | identifier: "user_1", 19 | plan: "default_plan", 20 | valid_from: ~U[2019-01-01 00:00:00Z], 21 | valid_until: ~U[2199-12-31 23:59:59Z] 22 | }, 23 | predated_license: %License{ 24 | identifier: "user_1", 25 | plan: "default_plan", 26 | valid_from: ~U[2199-01-01 00:00:00Z], 27 | valid_until: ~U[2199-12-31 23:59:59Z] 28 | }, 29 | }} 30 | end 31 | 32 | setup do 33 | {:ok, %{conn: conn(:post, "/a/fancy/api/call")}} 34 | end 35 | 36 | describe "fetch_license/1" do 37 | test "extracts an expired signed license", context do 38 | assert {:ok, signed_license} = Zachaeus.sign(context.expired_license) 39 | conn = put_req_header(context.conn, "authorization", "Bearer #{signed_license}") 40 | 41 | assert {_conn, {:ok, signed_license}} = Zachaeus.Plug.fetch_license(conn) 42 | assert is_binary(signed_license) 43 | assert byte_size(signed_license) > 0 44 | end 45 | 46 | test "extracts a valid signed license", context do 47 | assert {:ok, signed_license} = Zachaeus.sign(context.valid_license) 48 | conn = put_req_header(context.conn, "authorization", "Bearer #{signed_license}") 49 | 50 | assert {_conn, {:ok, signed_license}} = Zachaeus.Plug.fetch_license(conn) 51 | assert is_binary(signed_license) 52 | assert byte_size(signed_license) > 0 53 | end 54 | 55 | test "extracts a predated signed license", context do 56 | assert {:ok, signed_license} = Zachaeus.sign(context.predated_license) 57 | conn = put_req_header(context.conn, "authorization", "Bearer #{signed_license}") 58 | 59 | assert {_conn, {:ok, signed_license}} = Zachaeus.Plug.fetch_license(conn) 60 | assert is_binary(signed_license) 61 | assert byte_size(signed_license) > 0 62 | end 63 | 64 | test "errors on a missing signed license", context do 65 | conn = put_req_header(context.conn, "authorization", "Bearer") 66 | assert {_conn, {:error, %Error{code: :extraction_failed, message: "Unable to extract license from the HTTP Authorization request header"}}} = Zachaeus.Plug.fetch_license(conn) 67 | end 68 | end 69 | 70 | describe "verify_license/1" do 71 | test "passes on a valid signed license", context do 72 | assert {:ok, signed_license} = Zachaeus.sign(context.valid_license) 73 | assert {conn, {:ok, license}} = 74 | context.conn 75 | |> put_req_header("authorization", "Bearer #{signed_license}") 76 | |> Zachaeus.Plug.fetch_license() 77 | |> Zachaeus.Plug.verify_license() 78 | 79 | assert match?(%License{}, license) 80 | assert conn.private[:zachaeus_identifier] == context.valid_license.identifier 81 | assert conn.private[:zachaeus_plan] == context.valid_license.plan 82 | end 83 | 84 | test "passes on an expired signed license", context do 85 | assert {:ok, signed_license} = Zachaeus.sign(context.expired_license) 86 | assert {conn, {:ok, license}} = 87 | context.conn 88 | |> put_req_header("authorization", "Bearer #{signed_license}") 89 | |> Zachaeus.Plug.fetch_license() 90 | |> Zachaeus.Plug.verify_license() 91 | 92 | assert match?(%License{}, license) 93 | assert conn.private[:zachaeus_identifier] == context.expired_license.identifier 94 | assert conn.private[:zachaeus_plan] == context.expired_license.plan 95 | end 96 | 97 | test "passes on a predated signed license", context do 98 | assert {:ok, signed_license} = Zachaeus.sign(context.predated_license) 99 | assert {conn, {:ok, license}} = 100 | context.conn 101 | |> put_req_header("authorization", "Bearer #{signed_license}") 102 | |> Zachaeus.Plug.fetch_license() 103 | |> Zachaeus.Plug.verify_license() 104 | 105 | assert match?(%License{}, license) 106 | assert conn.private[:zachaeus_identifier] == context.predated_license.identifier 107 | assert conn.private[:zachaeus_plan] == context.predated_license.plan 108 | end 109 | 110 | test "errors on an invalid signed license", context do 111 | assert {_conn, {:error, %Error{}}} = 112 | context.conn 113 | |> put_req_header("authorization", "Bearer absolutely_invalid_signed_license") 114 | |> Zachaeus.Plug.fetch_license() 115 | |> Zachaeus.Plug.verify_license() 116 | end 117 | 118 | test "errors on a tampered signed license", context do 119 | assert {conn, {:error, %Error{code: :license_tampered}}} = 120 | context.conn 121 | |> put_req_header("authorization", "Bearer #{context.tampered_signed_license}") 122 | |> Zachaeus.Plug.fetch_license() 123 | |> Zachaeus.Plug.verify_license() 124 | end 125 | 126 | test "errors on a non matching function call", context do 127 | assert {_conn, {:error, %Error{}}} = Zachaeus.Plug.verify_license({context.conn, 12345}) 128 | end 129 | end 130 | 131 | describe "validate_license/1" do 132 | test "passes on a valid verified license", context do 133 | assert {:ok, signed_license} = Zachaeus.sign(context.valid_license) 134 | assert {conn, {:ok, license}} = 135 | context.conn 136 | |> put_req_header("authorization", "Bearer #{signed_license}") 137 | |> Zachaeus.Plug.fetch_license() 138 | |> Zachaeus.Plug.verify_license() 139 | |> Zachaeus.Plug.validate_license() 140 | 141 | assert match?(%License{}, license) 142 | assert conn.private[:zachaeus_identifier] == context.valid_license.identifier 143 | assert conn.private[:zachaeus_plan] == context.valid_license.plan 144 | assert is_integer(conn.private[:zachaeus_remaining_seconds]) 145 | assert conn.private[:zachaeus_remaining_seconds] > 0 146 | end 147 | 148 | test "errors on a verified but expired license", context do 149 | assert {:ok, signed_license} = Zachaeus.sign(context.expired_license) 150 | assert {_conn, {:error, %Zachaeus.Error{code: :license_expired}}} = 151 | context.conn 152 | |> put_req_header("authorization", "Bearer #{signed_license}") 153 | |> Zachaeus.Plug.fetch_license() 154 | |> Zachaeus.Plug.verify_license() 155 | |> Zachaeus.Plug.validate_license() 156 | end 157 | 158 | test "errors on a verified but predated license", context do 159 | assert {:ok, signed_license} = Zachaeus.sign(context.predated_license) 160 | assert {_conn, {:error, %Zachaeus.Error{code: :license_predated}}} = 161 | context.conn 162 | |> put_req_header("authorization", "Bearer #{signed_license}") 163 | |> Zachaeus.Plug.fetch_license() 164 | |> Zachaeus.Plug.verify_license() 165 | |> Zachaeus.Plug.validate_license() 166 | end 167 | 168 | test "errors on an invalid signed license", context do 169 | assert {_conn, {:error, %Error{}}} = 170 | context.conn 171 | |> put_req_header("authorization", "Bearer absolutely_invalid_signed_license") 172 | |> Zachaeus.Plug.fetch_license() 173 | |> Zachaeus.Plug.verify_license() 174 | |> Zachaeus.Plug.validate_license() 175 | end 176 | 177 | test "errors on a non matching function call", context do 178 | assert {_conn, {:error, %Error{}}} = Zachaeus.Plug.verify_license({context.conn, 12345}) 179 | end 180 | end 181 | 182 | describe "zachaeus_identifier/1" do 183 | test "extracts the zachaeus identifier in a connection", context do 184 | assert {:ok, signed_license} = Zachaeus.sign(context.valid_license) 185 | assert {conn, {:ok, license}} = 186 | context.conn 187 | |> put_req_header("authorization", "Bearer #{signed_license}") 188 | |> Zachaeus.Plug.fetch_license() 189 | |> Zachaeus.Plug.verify_license() 190 | |> Zachaeus.Plug.validate_license() 191 | 192 | assert Zachaeus.Plug.zachaeus_identifier(conn) == conn.private[:zachaeus_identifier] 193 | assert Zachaeus.Plug.zachaeus_identifier(conn) == context.valid_license.identifier 194 | end 195 | 196 | test "extracts nil for a non existant zachaeus identifier in a connection", context do 197 | assert Zachaeus.Plug.zachaeus_identifier(context.conn) == nil 198 | end 199 | end 200 | 201 | describe "zachaeus_plan/1" do 202 | test "extracts the zachaeus plan in a connection", context do 203 | assert {:ok, signed_license} = Zachaeus.sign(context.valid_license) 204 | assert {conn, {:ok, license}} = 205 | context.conn 206 | |> put_req_header("authorization", "Bearer #{signed_license}") 207 | |> Zachaeus.Plug.fetch_license() 208 | |> Zachaeus.Plug.verify_license() 209 | |> Zachaeus.Plug.validate_license() 210 | 211 | assert Zachaeus.Plug.zachaeus_plan(conn) == conn.private[:zachaeus_plan] 212 | assert Zachaeus.Plug.zachaeus_plan(conn) == context.valid_license.plan 213 | end 214 | 215 | test "extracts nil for a non existant zachaeus plan in a connection", context do 216 | assert Zachaeus.Plug.zachaeus_plan(context.conn) == nil 217 | end 218 | end 219 | 220 | describe "zachaeus_remaining_seconds/1" do 221 | test "extracts the zachaeus remaining seconds of a license in a connection", context do 222 | assert {:ok, signed_license} = Zachaeus.sign(context.valid_license) 223 | assert {conn, {:ok, license}} = 224 | context.conn 225 | |> put_req_header("authorization", "Bearer #{signed_license}") 226 | |> Zachaeus.Plug.fetch_license() 227 | |> Zachaeus.Plug.verify_license() 228 | |> Zachaeus.Plug.validate_license() 229 | 230 | assert Zachaeus.Plug.zachaeus_remaining_seconds(conn) == conn.private[:zachaeus_remaining_seconds] 231 | assert is_integer(Zachaeus.Plug.zachaeus_remaining_seconds(conn)) 232 | assert Zachaeus.Plug.zachaeus_remaining_seconds(conn) > 0 233 | end 234 | 235 | test "extracts nil for a non existant zachaeus remaining seconds of a license in a connection", context do 236 | assert Zachaeus.Plug.zachaeus_remaining_seconds(context.conn) == nil 237 | end 238 | end 239 | 240 | defmodule CustomAuthentication do 241 | use Zachaeus.Plug 242 | 243 | def init(opts), do: opts 244 | 245 | def call(conn, _opts) do 246 | build_response({conn, nil}) 247 | end 248 | 249 | def build_response({conn, _any_data}) do 250 | conn 251 | |> put_resp_content_type("text/plain") 252 | |> send_resp(418, "418 I'm a teapot") 253 | |> halt() 254 | end 255 | end 256 | 257 | test "custom plug usage", context do 258 | conn = CustomAuthentication.call(context.conn, []) 259 | 260 | assert conn.resp_body == "418 I'm a teapot" 261 | assert conn.status == 418 262 | assert conn.halted 263 | end 264 | end 265 | -------------------------------------------------------------------------------- /test/zachaeus_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ZachaeusTest do 2 | use ExUnit.Case, async: true 3 | doctest Zachaeus 4 | 5 | alias Zachaeus.{Error, License} 6 | alias Salty.Sign.Ed25519 7 | 8 | setup_all do 9 | {:ok, %{ 10 | license: %License{ 11 | identifier: "user_1", 12 | plan: "default_plan", 13 | valid_from: ~U[2019-01-01 00:00:00Z], 14 | valid_until: ~U[2199-12-31 23:59:59Z] 15 | }, 16 | serialized_license: "user_1|default_plan|1546300800|7258118399", 17 | signed_license: "QrCTnY52fLzoWquad1ZtYB6EXqjpBRm9dTdGP7cDw2Vl3fuHvZdodW2q0EFNCwvBnY1hxmkrdRDZgHk-NLIEAHVzZXJfMXxkZWZhdWx0X3BsYW58MTU0NjMwMDgwMHw3MjU4MTE4Mzk5", 18 | tampered_signed_license: "QrCTnY52fLzoWquad1ZtYB6EXqjpBRm9dTdGP7cDw2Vl3fuHvZdodW2q0EFNCwvBnY1hxmkrdRDZgHk-NLIEAHVzZXJfMXxkZWZhdWx0X3BsYW58MTU0NjMwMDgwMHw3MjU4MTE4Mzk4", 19 | public_key: Application.fetch_env!(:zachaeus, :public_key), 20 | secret_key: Application.fetch_env!(:zachaeus, :secret_key), 21 | }} 22 | end 23 | 24 | describe "sign/1" do 25 | setup context do 26 | on_exit(fn -> 27 | Application.put_env(:zachaeus, :secret_key, context.secret_key) 28 | end) 29 | end 30 | 31 | test "with an unconfigured secret key", context do 32 | Application.delete_env(:zachaeus, :secret_key) 33 | assert {:error, %Error{code: :secret_key_unconfigured}} = Zachaeus.sign(context.license) 34 | end 35 | 36 | test "with an invalid secret key", context do 37 | # -> nil 38 | secret_key = nil 39 | Application.put_env(:zachaeus, :secret_key, secret_key) 40 | assert {:error, %Error{code: :invalid_secret_key}} = Zachaeus.sign(context.license) 41 | 42 | # -> empty string 43 | secret_key = "" 44 | Application.put_env(:zachaeus, :secret_key, secret_key) 45 | assert {:error, %Error{code: :invalid_secret_key}} = Zachaeus.sign(context.license) 46 | 47 | # -> invalid string 48 | secret_key = "%%%%" 49 | Application.put_env(:zachaeus, :secret_key, secret_key) 50 | assert {:error, %Error{code: :decoding_failed}} = Zachaeus.sign(context.license) 51 | 52 | # -> invalid size 53 | secret_key = :crypto.strong_rand_bytes(63) |> Base.url_encode64(padding: false) 54 | Application.put_env(:zachaeus, :secret_key, secret_key) 55 | assert {:error, %Error{code: :invalid_secret_key}} = Zachaeus.sign(context.license) 56 | 57 | # -> invalid type 58 | secret_key = 123 59 | Application.put_env(:zachaeus, :secret_key, secret_key) 60 | assert {:error, %Error{code: :invalid_secret_key}} = Zachaeus.sign(context.license) 61 | end 62 | 63 | test "with valid data", context do 64 | assert {:ok, signed_license} = Zachaeus.sign(context.license) 65 | assert {:ok, decoded_signed_license} = Base.url_decode64(signed_license, padding: false) 66 | assert <<_signature::binary-size(64), serialized_license::binary>> = decoded_signed_license 67 | assert serialized_license == context.serialized_license 68 | end 69 | end 70 | 71 | describe "sign/2" do 72 | test "with an invalid secret key", context do 73 | # -> nil 74 | secret_key = nil 75 | assert {:error, %Error{code: :invalid_secret_key}} = Zachaeus.sign(context.license, secret_key) 76 | 77 | # -> empty string 78 | secret_key = "" 79 | assert {:error, %Error{code: :invalid_secret_key}} = Zachaeus.sign(context.license, secret_key) 80 | 81 | # -> invalid size 82 | secret_key = :crypto.strong_rand_bytes(63) |> Base.url_encode64(padding: false) 83 | assert {:error, %Error{code: :invalid_secret_key}} = Zachaeus.sign(context.license, secret_key) 84 | 85 | # -> invalid type 86 | secret_key = 123 87 | assert {:error, %Error{code: :invalid_secret_key}} = Zachaeus.sign(context.license, secret_key) 88 | end 89 | 90 | test "with valid data", context do 91 | assert {:ok, secret_key} = Base.url_decode64(context.secret_key, padding: false) 92 | assert {:ok, signed_license} = Zachaeus.sign(context.license, secret_key) 93 | assert {:ok, decoded_signed_license} = Base.url_decode64(signed_license, padding: false) 94 | assert <<_signature::binary-size(64), serialized_license::binary>> = decoded_signed_license 95 | assert serialized_license == context.serialized_license 96 | end 97 | end 98 | 99 | describe "verify/1" do 100 | setup context do 101 | on_exit(fn -> 102 | Application.put_env(:zachaeus, :public_key, context.public_key) 103 | end) 104 | end 105 | 106 | test "with an unconfigured public key", context do 107 | Application.delete_env(:zachaeus, :public_key) 108 | assert {:error, %Error{code: :public_key_unconfigured}} = Zachaeus.verify(context.signed_license) 109 | end 110 | 111 | test "with an invalid public key", context do 112 | # -> nil 113 | public_key = nil 114 | Application.put_env(:zachaeus, :public_key, public_key) 115 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.verify(context.signed_license) 116 | 117 | # -> empty string 118 | public_key = "" 119 | Application.put_env(:zachaeus, :public_key, public_key) 120 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.verify(context.signed_license) 121 | 122 | # -> invalid string 123 | public_key = "%%%%" 124 | Application.put_env(:zachaeus, :public_key, public_key) 125 | assert {:error, %Error{code: :decoding_failed}} = Zachaeus.verify(context.signed_license) 126 | 127 | # -> invalid size 128 | public_key = :crypto.strong_rand_bytes(63) |> Base.url_encode64(padding: false) 129 | Application.put_env(:zachaeus, :public_key, public_key) 130 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.verify(context.signed_license) 131 | 132 | # -> invalid type 133 | public_key = 123 134 | Application.put_env(:zachaeus, :public_key, public_key) 135 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.verify(context.signed_license) 136 | end 137 | 138 | test "with a tampered license", context do 139 | assert {:error, %Error{code: :license_tampered}} = Zachaeus.verify(context.tampered_signed_license) 140 | end 141 | 142 | test "with valid data", context do 143 | assert {:ok, license} = Zachaeus.verify(context.signed_license) 144 | assert {:ok, decoded_signed_license} = Base.url_decode64(context.signed_license, padding: false) 145 | assert <> = decoded_signed_license 146 | assert {:ok, decoded_public_key} = Base.url_decode64(context.public_key, padding: false) 147 | assert :ok == Ed25519.verify_detached(signature, serialized_license, decoded_public_key) 148 | assert license == context.license 149 | end 150 | end 151 | 152 | describe "verify/2" do 153 | test "with an invalid public key", context do 154 | # -> nil 155 | public_key = nil 156 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.verify(context.signed_license, public_key) 157 | 158 | # -> empty string 159 | public_key = "" 160 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.verify(context.signed_license, public_key) 161 | 162 | # -> invalid size 163 | public_key = :crypto.strong_rand_bytes(63) |> Base.url_encode64(padding: false) 164 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.verify(context.signed_license, public_key) 165 | 166 | # -> invalid type 167 | public_key = 123 168 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.verify(context.signed_license, public_key) 169 | end 170 | 171 | test "with a tampered license", context do 172 | assert {:ok, decoded_public_key} = Base.url_decode64(context.public_key, padding: false) 173 | assert {:error, %Error{code: :license_tampered}} = Zachaeus.verify(context.tampered_signed_license, decoded_public_key) 174 | end 175 | 176 | test "with valid data", context do 177 | assert {:ok, decoded_public_key} = Base.url_decode64(context.public_key, padding: false) 178 | assert {:ok, license} = Zachaeus.verify(context.signed_license, decoded_public_key) 179 | assert {:ok, decoded_signed_license} = Base.url_decode64(context.signed_license, padding: false) 180 | assert <> = decoded_signed_license 181 | assert :ok == Ed25519.verify_detached(signature, serialized_license, decoded_public_key) 182 | assert license == context.license 183 | end 184 | end 185 | 186 | describe "validate/1" do 187 | setup context do 188 | on_exit(fn -> 189 | Application.put_env(:zachaeus, :public_key, context.public_key) 190 | end) 191 | end 192 | 193 | test "with an unconfigured public key", context do 194 | Application.delete_env(:zachaeus, :public_key) 195 | assert {:error, %Error{code: :public_key_unconfigured}} = Zachaeus.validate(context.signed_license) 196 | end 197 | 198 | test "with an invalid public key", context do 199 | # -> nil 200 | public_key = nil 201 | Application.put_env(:zachaeus, :public_key, public_key) 202 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.validate(context.signed_license) 203 | 204 | # -> empty string 205 | public_key = "" 206 | Application.put_env(:zachaeus, :public_key, public_key) 207 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.validate(context.signed_license) 208 | 209 | # -> invalid string 210 | public_key = "%%%%" 211 | Application.put_env(:zachaeus, :public_key, public_key) 212 | assert {:error, %Error{code: :decoding_failed}} = Zachaeus.validate(context.signed_license) 213 | 214 | # -> invalid size 215 | public_key = :crypto.strong_rand_bytes(63) |> Base.url_encode64(padding: false) 216 | Application.put_env(:zachaeus, :public_key, public_key) 217 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.validate(context.signed_license) 218 | 219 | # -> invalid type 220 | public_key = 123 221 | Application.put_env(:zachaeus, :public_key, public_key) 222 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.validate(context.signed_license) 223 | end 224 | 225 | test "with a tampered license", context do 226 | assert {:error, %Error{code: :license_tampered}} = Zachaeus.validate(context.tampered_signed_license) 227 | end 228 | 229 | test "with valid data", context do 230 | assert Zachaeus.validate(context.signed_license) == License.validate(context.license) 231 | end 232 | end 233 | 234 | describe "validate/2" do 235 | test "with an invalid public key", context do 236 | # -> nil 237 | public_key = nil 238 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.validate(context.signed_license, public_key) 239 | 240 | # -> empty string 241 | public_key = "" 242 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.validate(context.signed_license, public_key) 243 | 244 | # -> invalid size 245 | public_key = :crypto.strong_rand_bytes(63) |> Base.url_encode64(padding: false) 246 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.validate(context.signed_license, public_key) 247 | 248 | # -> invalid type 249 | public_key = 123 250 | assert {:error, %Error{code: :invalid_public_key}} = Zachaeus.validate(context.signed_license, public_key) 251 | end 252 | 253 | test "with a tampered license", context do 254 | assert {:ok, decoded_public_key} = Base.url_decode64(context.public_key, padding: false) 255 | assert {:error, %Error{code: :license_tampered}} = Zachaeus.validate(context.tampered_signed_license, decoded_public_key) 256 | end 257 | 258 | test "with valid data", context do 259 | assert {:ok, decoded_public_key} = Base.url_decode64(context.public_key, padding: false) 260 | assert Zachaeus.validate(context.signed_license, decoded_public_key) == License.validate(context.license) 261 | end 262 | end 263 | 264 | describe "valid?/1" do 265 | setup context do 266 | on_exit(fn -> 267 | Application.put_env(:zachaeus, :public_key, context.public_key) 268 | end) 269 | end 270 | 271 | test "with an unconfigured public key", context do 272 | Application.delete_env(:zachaeus, :public_key) 273 | refute Zachaeus.valid?(context.signed_license) 274 | end 275 | 276 | test "with an invalid public key", context do 277 | # -> nil 278 | public_key = nil 279 | Application.put_env(:zachaeus, :public_key, public_key) 280 | refute Zachaeus.valid?(context.signed_license) 281 | 282 | # -> empty string 283 | public_key = "" 284 | Application.put_env(:zachaeus, :public_key, public_key) 285 | refute Zachaeus.valid?(context.signed_license) 286 | 287 | # -> invalid string 288 | public_key = "%%%%" 289 | Application.put_env(:zachaeus, :public_key, public_key) 290 | refute Zachaeus.valid?(context.signed_license) 291 | 292 | # -> invalid size 293 | public_key = :crypto.strong_rand_bytes(63) |> Base.url_encode64(padding: false) 294 | Application.put_env(:zachaeus, :public_key, public_key) 295 | refute Zachaeus.valid?(context.signed_license) 296 | 297 | # -> invalid type 298 | public_key = 123 299 | Application.put_env(:zachaeus, :public_key, public_key) 300 | refute Zachaeus.valid?(context.signed_license) 301 | end 302 | 303 | test "with a tampered license", context do 304 | refute Zachaeus.valid?(context.tampered_signed_license) 305 | end 306 | 307 | test "with valid data", context do 308 | assert Zachaeus.valid?(context.signed_license) 309 | assert License.valid?(context.license) 310 | assert Zachaeus.valid?(context.signed_license) == License.valid?(context.license) 311 | end 312 | end 313 | 314 | describe "valid?/2" do 315 | test "with an invalid public key", context do 316 | # -> nil 317 | public_key = nil 318 | refute Zachaeus.valid?(context.signed_license, public_key) 319 | 320 | # -> empty string 321 | public_key = "" 322 | refute Zachaeus.valid?(context.signed_license, public_key) 323 | 324 | # -> invalid size 325 | public_key = :crypto.strong_rand_bytes(63) |> Base.url_encode64(padding: false) 326 | refute Zachaeus.valid?(context.signed_license, public_key) 327 | 328 | # -> invalid type 329 | public_key = 123 330 | refute Zachaeus.valid?(context.signed_license, public_key) 331 | end 332 | 333 | test "with a tampered license", context do 334 | assert {:ok, decoded_public_key} = Base.url_decode64(context.public_key, padding: false) 335 | refute Zachaeus.valid?(context.tampered_signed_license, decoded_public_key) 336 | end 337 | 338 | test "with valid data", context do 339 | assert {:ok, decoded_public_key} = Base.url_decode64(context.public_key, padding: false) 340 | assert Zachaeus.valid?(context.signed_license, decoded_public_key) 341 | assert License.valid?(context.license) 342 | assert Zachaeus.valid?(context.signed_license, decoded_public_key) == License.valid?(context.license) 343 | end 344 | end 345 | end 346 | --------------------------------------------------------------------------------