├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── elixir.yml ├── .gitignore ├── LICENSE ├── README.md ├── lib ├── certbot.ex └── certbot │ ├── acme │ ├── authorization.ex │ ├── challenge.ex │ ├── challenge_store.ex │ ├── challenge_store │ │ └── default.ex │ ├── client.ex │ └── plug.ex │ ├── certificate.ex │ ├── certificate_store.ex │ ├── certificate_store │ └── default.ex │ ├── config.ex │ ├── error.ex │ ├── logger.ex │ ├── provider.ex │ ├── provider │ ├── acme.ex │ └── static.ex │ └── ssl.ex ├── mix.exs ├── mix.lock └── test ├── certbot ├── acme │ ├── authorization_test.exs │ ├── challenge_test.exs │ └── plug_test.exs ├── certificate_test.exs └── provider │ ├── acme_test.exs │ └── static_test.exs ├── certbot_test.exs ├── fixtures ├── selfsigned.pem └── selfsigned_key.pem └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: ex_doc 11 | versions: 12 | - 0.24.1 13 | - dependency-name: credo 14 | versions: 15 | - 1.5.4 16 | - dependency-name: plug 17 | versions: 18 | - 1.11.0 19 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 7 | strategy: 8 | matrix: 9 | otp: [21.x, 22.x] 10 | elixir: [1.9.x] 11 | steps: 12 | - uses: actions/checkout@v1.0.0 13 | - uses: actions/setup-elixir@v1.0.0 14 | with: 15 | otp-version: ${{matrix.otp}} 16 | elixir-version: ${{matrix.elixir}} 17 | - name: Check formatting 18 | if: matrix.elixir == '1.9.x' 19 | run: mix format --check-formatted 20 | - name: Install Dependencies 21 | run: mix deps.get 22 | - name: Check style 23 | if: matrix.elixir == '1.9.x' 24 | run: mix credo --strict 25 | - name: Compile project 26 | run: mix compile --warnings-as-errors 27 | - name: Run tests 28 | run: mix test --cover 29 | -------------------------------------------------------------------------------- /.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 | certbot-*.tar 24 | 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Maarten van Vliet 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 | # Certbot 2 | [![Build Status](https://travis-ci.com/maartenvanvliet/certbot.svg?branch=master)](https://travis-ci.com/maartenvanvliet/certbot) [![Hex pm](http://img.shields.io/hexpm/v/certbot.svg?style=flat)](https://hex.pm/packages/certbot) [![Hex Docs](https://img.shields.io/badge/hex-docs-9768d1.svg)](https://hexdocs.pm/certbot) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 3 | ----- 4 | 5 | Provide certificates for your Phoenix or Plug app using Letsencrypt. 6 | 7 | This package should for now be considered a POC. Not everything is implemented 8 | at the moment, most notably, certificate renewal. 9 | 10 | You can also set your own Certificate Provider for your own functionality, or 11 | to provide different certificates for different hostnames. 12 | 13 | ## Installation 14 | 15 | The package can be installed 16 | by adding `certbot` to your list of dependencies in `mix.exs`: 17 | 18 | ```elixir 19 | def deps do 20 | [ 21 | {:certbot, "~> 0.5.0"} 22 | ] 23 | end 24 | ``` 25 | 26 | ### Setting up Letsencrypt with Phoenix 27 | From then on there are a few steps, we need to setup a certbot client, a store 28 | for the certificates and a store for Acme challenges. Furthermore we need to setup 29 | `Certbot.Acme.Plug` to verify Acme challenges over http. 30 | 31 | ### Certbot client 32 | First, a certbot client is needed. We use a self generated private key to build 33 | into a JWK. If you have a Phoenix project, this can be generated with 34 | `mix phx.gen.cert` 35 | 36 | Furthermore, we set an AcmeCertificateProvider 37 | ```elixir 38 | defmodule Myapp.CertbotClient do 39 | @jwk "priv/cert/selfsigned_key.pem" 40 | |> File.read!() 41 | |> JOSE.JWK.from_pem() 42 | |> JOSE.JWK.to_map() 43 | 44 | use Certbot, 45 | certificate_provider: Myapp.AcmeCertificateProvider, 46 | jwk: @jwk, 47 | email: "mailto:test@example.com" 48 | end 49 | 50 | defmodule Myapp.AcmeCertificateProvider do 51 | use Certbot.Provider.Acme, 52 | challenge_store: Certbot.Acme.ChallengeStore.Default, 53 | certificate_store: Certbot.CertificateStore.Default, 54 | acme_client: Myapp.CertbotClient 55 | end 56 | ``` 57 | 58 | The `Myapp.CertbotClient` doubles as an Acme client, and therefore needs to be added 59 | to the supervision tree of your application. We use, the default challenge/certificate 60 | stores of the package, they also need to be added your application supervision 61 | tree. Note, there are downsides to the stores, see their docs for more info. 62 | 63 | Your supervision tree will look something like this in a Phoenix project 64 | ```elixir 65 | # application.ex 66 | 67 | children = [ 68 | # Start the Ecto repository 69 | Myapp.Repo, 70 | # Start the endpoint when the application starts 71 | MyappWeb.Endpoint, 72 | Myapp.CertbotClient, 73 | Certbot.Acme.ChallengeStore.Default, 74 | Certbot.CertificateStore.Default 75 | ] 76 | ``` 77 | 78 | In your `endpoint.ex` you should add `Certbot.Acme.Plug`, with the same challenge 79 | store and `jwk`. 80 | 81 | It should be added before the router, and before Plug.SSL if force SSL redirects 82 | are turned on. 83 | 84 | ```elixir 85 | # endpoint.ex 86 | @jwk "priv/cert/selfsigned_key.pem" |> File.read!() |> JOSE.JWK.from_pem() |> JOSE.JWK.to_map() 87 | 88 | plug Certbot.Acme.Plug, challenge_store: Certbot.Acme.ChallengeStore.Default, jwk: @jwk 89 | plug MyappWeb.Router 90 | ``` 91 | 92 | As a last step we need configure the https endpoint to dynamically return certificates. 93 | 94 | ```elixir 95 | config :myapp, MyappWeb.Endpoint, 96 | http: [port: 6000], 97 | https: [ 98 | cipher_suite: :strong, 99 | port: 6001, 100 | sni_fun: &Myapp.CertbotClient.sni_fun/1 #Set the sni_fun 101 | ], 102 | ``` 103 | 104 | This tells cowboy to call `sni_fun/1` with the hostname of the request. This 105 | function will ask the certificate provider for a certificate. The certificate provider 106 | will return one, or first request one from Letsencrypt and then return it. 107 | 108 | ## FAQ 109 | Is this tested in production? 110 | - No, be careful 111 | 112 | Can I test this against a non-production acme server 113 | - Yes, you need to set the `:server` to `https://acme-staging.api.letsencrypt.org/` 114 | 115 | Does it do certificate renewal? 116 | - Not yet, should not be really hard to do. Every now and then a sweep of the 117 | certificate store to check for certificates that are about to expire, and renew 118 | a certificate for them. 119 | 120 | How can I test this locally? 121 | - You need to make sure port 80 is available for the Acme server to request with 122 | the token verification call. You'll need to map port 80 to the https port you 123 | configured your endpoint to. 124 | 125 | Are multiple account keys supported? 126 | - No, not yet. But willing to accept PR's. 127 | 128 | How are multiple concurrent requests handled with certification requests? 129 | - Nothing is done, ideally some kind of lock is placed so requests after the first 130 | one will wait till a certificate is retrieved and then use this certificate. Nothing of the kind is done. 131 | 132 | What happens if I request too many certificates? 133 | - You'll be ratelimited by Letsencrypt 134 | 135 | I am debugging but don't see errors appearing? 136 | - Because everything happens as a result of calling the `sni_fun/1` callback, 137 | this is at such a level that many errors don't seem to appear. 138 | 139 | What version of the Acme protocol is used? 140 | - Acme V1 is used, many thanks to this package: https://github.com/sikanhe/acme 141 | 142 | Are alternative challenge methods (`dns-01`, `tls-sni-01`,`tls-alpn-01`)? 143 | - No, `tls-alpn-01` is currently not supported by the Acme client but would be interesting 144 | as it would make it unnecessary te expose port 80. `tls-sni-01` is not secure and 145 | `dns-01` is out of scope as of now. 146 | 147 | 148 | ### Errors 149 | ```[error] %Certbot.Error{detail: "JWS has invalid anti-replay nonce twT0up7DWSrbe163DiRuKnPwd4ZpyXVER0p-COl1vAA", status: 400, type: "urn:acme:error:badNonce"}``` 150 | - This is not handled yet, the nonce should be refreshed and then the request repeated. 151 | 152 | ```[error] Certbot.Acme.Client Certbot.Acme.Client received unexpected message in handle_info/2: {:ssl_closed, {:sslsocket, {:gen_tcp, #Port<0.76>, :tls_connection, :undefined}, [#PID<0.851.0>, #PID<0.850.0>]}}``` 153 | - Don't know why this happens... 154 | 155 | ## Documentation 156 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 157 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 158 | be found at [https://hexdocs.pm/certbot](https://hexdocs.pm/certbot). 159 | 160 | -------------------------------------------------------------------------------- /lib/certbot.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot do 2 | @external_resource "./README.md" 3 | @moduledoc """ 4 | #{File.read!("./README.md") |> String.split("-----", parts: 2) |> List.last()} 5 | """ 6 | 7 | defmacro __using__(opts) do 8 | quote location: :keep do 9 | @defaults unquote(opts) 10 | 11 | @client Certbot.Acme.Client 12 | @doc """ 13 | Callback called by the SSL layer in OTP 14 | See http://erlang.org/doc/man/ssl.html 15 | Returns the der encoded certificate, public/private key pair for the 16 | ssl handshake 17 | """ 18 | def sni_fun(hostname) do 19 | opts = @defaults 20 | 21 | Certbot.sni_fun(List.to_string(hostname), opts) 22 | end 23 | 24 | use GenServer 25 | 26 | def start_link(opts, name \\ @client) do 27 | opts = Keyword.merge(@defaults, opts) 28 | Certbot.start_link(opts, name) 29 | end 30 | 31 | def init(init_arg) do 32 | {:ok, init_arg} 33 | end 34 | 35 | def authorize(hostname) do 36 | GenServer.call(@client, {:authorize, hostname}) 37 | end 38 | 39 | def respond_challenge(challenge) do 40 | GenServer.call(@client, {:respond_challenge, challenge}) 41 | end 42 | 43 | def new_certificate(csr) do 44 | GenServer.call(@client, {:new_certificate, csr}) 45 | end 46 | 47 | def get_certificate(csr) do 48 | GenServer.call(@client, {:get_certificate, csr}) 49 | end 50 | end 51 | end 52 | 53 | def start_link(opts, name \\ Certbot.Acme.Client) do 54 | config = Certbot.Config.new(opts) 55 | GenServer.start_link(Certbot.Acme.Client, config, name: name) 56 | end 57 | 58 | def sni_fun(hostname, opts) do 59 | config = Certbot.Config.new(opts) 60 | 61 | case config.certificate_provider.get_by_hostname(hostname) do 62 | %Certbot.Certificate{cert: cert, key: key} = certificate -> 63 | serial = Certbot.Certificate.hex_serial(certificate) 64 | config.logger.log(:info, "Serving #{hostname} with certificate #{serial}") 65 | # Serve the dynamic cert 66 | [ 67 | cert: cert, 68 | key: key 69 | ] 70 | 71 | # Do nothing, serves up the static tls config configured for your domains if set 72 | # will fail on other domains but nothing we can do 73 | _ -> 74 | config.logger.log(:info, "No certificate found for #{hostname}") 75 | [] 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/certbot/acme/authorization.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Acme.Authorization do 2 | @moduledoc """ 3 | Module for handling an `Acme.Authorization` response, converted from an 4 | `%Acme.Authorization{}` struct 5 | """ 6 | @type t :: %__MODULE__{ 7 | status: String.t(), 8 | identifier: any(), 9 | expires: any, 10 | challenges: list(Certbot.Acme.t()) 11 | } 12 | 13 | defstruct [:status, :identifier, :expires, :challenges] 14 | 15 | alias Certbot.Acme.Challenge 16 | 17 | @types ["http-01", "dns-01", "tls-sni-01"] 18 | @spec from_map(Acme.Authorization.t()) :: Certbot.Acme.Authorization.t() 19 | def from_map(%Acme.Authorization{ 20 | status: status, 21 | expires: expires, 22 | identifier: identifier, 23 | challenges: challenges 24 | }) do 25 | %__MODULE__{ 26 | status: status, 27 | expires: expires, 28 | identifier: identifier, 29 | challenges: Enum.map(challenges, &Challenge.from_struct/1) 30 | } 31 | end 32 | 33 | @doc """ 34 | Fetch specific challenge by type from an authorization struct 35 | 36 | Possible types: #{@types |> Enum.map(&"`#{&1}` ")} 37 | 38 | ## Example 39 | ``` 40 | iex> Certbot.Acme.Authorization.fetch_challenge(%Certbot.Acme.Authorization{challenges: []}, "http-01") 41 | nil 42 | 43 | iex> challenge = %Certbot.Acme.Challenge{type: "http-01"} 44 | iex> Certbot.Acme.Authorization.fetch_challenge(%Certbot.Acme.Authorization{challenges: [challenge]}, "http-01") 45 | %Certbot.Acme.Challenge{ 46 | status: nil, 47 | token: nil, 48 | type: "http-01", 49 | uri: nil 50 | } 51 | """ 52 | @spec fetch_challenge(Certbot.Acme.Authorization.t(), String.t()) :: any 53 | def fetch_challenge(%__MODULE__{challenges: challenges}, type) 54 | when type in ["http-01", "dns-01", "tls-sni-01"] do 55 | Enum.find(challenges, &(&1.type == to_string(type))) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/certbot/acme/challenge.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Acme.Challenge do 2 | @moduledoc """ 3 | Struct with utility functions for dealing with Acme Challenges 4 | """ 5 | @type t :: %__MODULE__{ 6 | token: String.t(), 7 | status: String.t(), 8 | type: String.t(), 9 | uri: String.t() 10 | } 11 | 12 | defstruct [:token, :status, :type, :uri] 13 | 14 | @doc """ 15 | Return authorization token given a challenge and the jwk used to generate it 16 | 17 | ## Example 18 | ``` 19 | iex> jwk = "test/fixtures/selfsigned_key.pem" |> File.read!() |> JOSE.JWK.from_pem() |> JOSE.JWK.to_map() 20 | iex> Certbot.Acme.Challenge.authorization("some_token", jwk) 21 | "some_token.v5Co8pJG2fo_hBcdhEzpj_DSEcev76KkbFQkJRiu-Cg" 22 | ``` 23 | """ 24 | @spec authorization(binary | %{token: any}, any) :: String.t() 25 | def authorization(token, jwk) when is_binary(token) do 26 | thumbprint = JOSE.JWK.thumbprint(jwk) 27 | "#{token}.#{thumbprint}" 28 | end 29 | 30 | def authorization(%__MODULE__{token: token}, jwk) do 31 | __MODULE__.authorization(token, jwk) 32 | end 33 | 34 | @doc """ 35 | Convert structs of the same shape, to a `Certbot.Acme.Challenge` struct. 36 | 37 | ## Example 38 | ``` 39 | iex> Certbot.Acme.Challenge.from_struct(%Acme.Challenge{type: "http-01"}) 40 | %Certbot.Acme.Challenge{ 41 | status: nil, 42 | token: nil, 43 | type: "http-01", 44 | uri: nil 45 | } 46 | ``` 47 | """ 48 | def from_struct(map) do 49 | map = Map.from_struct(map) 50 | struct(__MODULE__, map) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/certbot/acme/challenge_store.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Acme.ChallengeStore do 2 | @moduledoc """ 3 | Behaviour for storing and finding challenges 4 | 5 | Implement this behaviour for a challengestore based on another persistence 6 | mechanism. 7 | """ 8 | @callback find_by_token(token :: String.t()) :: 9 | {:ok, challenge :: Certbot.Challenge.t()} | {:error, String.t()} 10 | 11 | @callback insert(challenge :: Certbot.Challenge.t()) :: :ok 12 | end 13 | -------------------------------------------------------------------------------- /lib/certbot/acme/challenge_store/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Acme.ChallengeStore.Default do 2 | @moduledoc """ 3 | Default Acme.ChallengeStore 4 | 5 | Used to store challenges provided by the Acme server. This is a simple genserver, 6 | if you have multiple servers running the challenges won't be distributed among 7 | them so the challenges will fail. 8 | 9 | To counter this you'll need to reimplement this store with another one, e.g. 10 | based on mnesia or redis, and should implement the Certbot.Acme.ChallengeStore 11 | behaviour 12 | 13 | """ 14 | @behaviour Certbot.Acme.ChallengeStore 15 | 16 | use GenServer 17 | 18 | @spec start_link(any) :: :ignore | {:error, any} | {:ok, pid} 19 | def start_link(_) do 20 | GenServer.start_link(__MODULE__, [], name: __MODULE__) 21 | end 22 | 23 | @impl true 24 | def init(_) do 25 | {:ok, %{}} 26 | end 27 | 28 | @impl true 29 | def handle_cast({:insert, challenge}, state) do 30 | {:noreply, Map.put(state, challenge.token, challenge)} 31 | end 32 | 33 | @impl true 34 | def handle_call({:find_by_token, token}, _from, state) do 35 | result = 36 | case Map.get(state, token, nil) do 37 | nil -> {:error, "No such challenge"} 38 | challenge -> {:ok, challenge} 39 | end 40 | 41 | {:reply, result, state} 42 | end 43 | 44 | @spec find_by_token(String.t()) :: {:ok, Certbot.Challenge.t()} | {:error, String.t()} 45 | @impl true 46 | def find_by_token(token) do 47 | GenServer.call(__MODULE__, {:find_by_token, token}) 48 | end 49 | 50 | @impl true 51 | def insert(challenge) do 52 | GenServer.cast(__MODULE__, {:insert, challenge}) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/certbot/acme/client.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Acme.Client do 2 | @moduledoc false 3 | use GenServer 4 | 5 | def init(config) do 6 | {:ok, conn} = 7 | Acme.Client.start_link( 8 | server: config.server, 9 | private_key: config.jwk 10 | ) 11 | |> convert_error 12 | 13 | case config.email |> Acme.register() |> request(conn) do 14 | {:ok, registration} -> 15 | registration |> Acme.agree_terms() |> request(conn) 16 | 17 | {:error, %Acme.Error{status: 409}} -> 18 | {:ok, nil} 19 | 20 | {:error, error} -> 21 | error 22 | end 23 | 24 | {:ok, {config, conn}} 25 | end 26 | 27 | def handle_call({:authorize, hostname}, _from, {_config, conn} = state) do 28 | result = hostname |> Acme.authorize() |> request(conn) |> convert_authorization 29 | 30 | {:reply, result, state} 31 | end 32 | 33 | def handle_call({:respond_challenge, challenge}, _from, {_config, conn} = state) do 34 | challenge = struct(Acme.Challenge, Map.from_struct(challenge)) 35 | 36 | result = 37 | challenge 38 | |> Acme.respond_challenge() 39 | |> request(conn) 40 | |> convert_authorization 41 | 42 | {:reply, result, state} 43 | end 44 | 45 | def handle_call({:new_certificate, csr}, _from, {_config, conn} = state) do 46 | result = 47 | csr 48 | |> Acme.new_certificate() 49 | |> request(conn) 50 | 51 | {:reply, result, state} 52 | end 53 | 54 | def handle_call({:get_certificate, url}, _from, {_config, conn} = state) do 55 | result = 56 | url 57 | |> Acme.get_certificate() 58 | |> request(conn) 59 | 60 | {:reply, result, state} 61 | end 62 | 63 | defp convert_error(result) do 64 | case result do 65 | {:error, %Acme.Error{} = error} -> {:error, Certbot.Error.from_struct(error)} 66 | result -> result 67 | end 68 | end 69 | 70 | defp convert_authorization(result) do 71 | case result do 72 | {:ok, %Acme.Authorization{} = authorization} -> 73 | {:ok, Certbot.Acme.Authorization.from_map(authorization)} 74 | 75 | error -> 76 | error 77 | end 78 | end 79 | 80 | defp request(request, conn) do 81 | request 82 | |> Acme.request(conn) 83 | |> convert_error 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/certbot/acme/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Acme.Plug do 2 | @moduledoc """ 3 | Plug used to intercept challenge verification calls on the request path 4 | `/.well-known/acme-challenge/`. 5 | 6 | The plug can be placed early in the pipeline. When using Phoenix, it should 7 | be placed before your router in your `endpoint.ex`. 8 | 9 | If you plan on redirecting http to https using Plug.SSL, place it after this plug. 10 | `Certbot.Acme.Plug` needs to work over http. 11 | 12 | It requires two options. 13 | - `:challenge_store` -- The challenge store used, so when a verication call 14 | comes in, it can check whether it knows the token. It needs to be the same store 15 | where the `Certbot.Provider.Acme` provider stores the challenges. 16 | - `:jwk` -- A jwk map, see below for an example on how to generate one from 17 | a private key. 18 | 19 | ## Example 20 | ``` 21 | @jwk "priv/cert/selfsigned_key.pem" |> File.read!() |> JOSE.JWK.from_pem() |> JOSE.JWK.to_map() 22 | 23 | plug Certbot.Acme.Plug, challenge_store: Certbot.ChallengeStore.Default, jwk: @jwk 24 | ``` 25 | 26 | """ 27 | alias Certbot.Acme.Challenge 28 | 29 | @spec init(any) :: {atom, any} 30 | def init(opts) do 31 | challenge_store = Keyword.fetch!(opts, :challenge_store) 32 | jwk = Keyword.fetch!(opts, :jwk) 33 | 34 | validate_jwk!(jwk) 35 | 36 | {challenge_store, jwk} 37 | end 38 | 39 | @spec call(Plug.Conn.t(), {atom, any}) :: Plug.Conn.t() 40 | def call(conn, {challenge_store, jwk}) do 41 | case conn.request_path do 42 | "/.well-known/acme-challenge/" <> token -> 43 | reply_challenge(conn, token, {challenge_store, jwk}) 44 | 45 | _ -> 46 | conn 47 | end 48 | end 49 | 50 | defp reply_challenge(conn, token, {challenge_store, jwk}) do 51 | case challenge_store.find_by_token(token) do 52 | {:ok, challenge} -> 53 | authorization = Challenge.authorization(challenge, jwk) 54 | 55 | conn 56 | |> Plug.Conn.send_resp(200, authorization) 57 | |> Plug.Conn.halt() 58 | 59 | _ -> 60 | conn 61 | |> Plug.Conn.send_resp(404, "Not found") 62 | |> Plug.Conn.halt() 63 | end 64 | end 65 | 66 | defp validate_jwk!(jwk) do 67 | %JOSE.JWK{} = JOSE.JWK.from_map(jwk) 68 | jwk 69 | rescue 70 | _ -> reraise ArgumentError, "Invalid jwk supplied to `Certbot.Acme.Plug`" 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/certbot/certificate.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Certificate do 2 | @moduledoc """ 3 | The module provides utility functions to deal with the serial and validity 4 | timestamps of a certificate as well as being a struct to store certificate/keys 5 | """ 6 | 7 | @type t :: %__MODULE__{ 8 | cert: binary(), 9 | key: {atom(), binary()} 10 | } 11 | 12 | defstruct [:cert, :key] 13 | 14 | @algo [:RSAPrivateKey, :ECPrivateKey] 15 | 16 | @spec build(binary, {:ECPrivateKey, binary} | {:RSAPrivateKey, binary}) :: 17 | Certbot.Certificate.t() 18 | @doc """ 19 | Build struct to store a certificate in der format and its private key 20 | 21 | For example, to generate a 22 | ```elixir 23 | cert_file = File.read!("priv/cert/selfsigned.pem") 24 | key_file = File.read!("priv/cert/selfsigned_key.pem") 25 | 26 | [certificate] = :public_key.pem_decode(cert_file) 27 | cert = :public_key.pem_entry_decode(certificate) 28 | cert = :public_key.der_encode(:Certificate, cert) 29 | 30 | [key] = :public_key.pem_decode(key_file) 31 | key = :public_key.pem_entry_decode(key) 32 | der_key = :public_key.der_encode(:RSAPrivateKey, key) 33 | 34 | Certbot.Certificate.build(cert, {:RSAPrivateKey, der_key}) 35 | ``` 36 | """ 37 | def build(cert, {algo, der_key}) 38 | when algo in @algo and is_binary(cert) and is_binary(der_key) do 39 | %__MODULE__{ 40 | cert: cert, 41 | key: {algo, der_key} 42 | } 43 | end 44 | 45 | @spec serial(Certbot.Certificate.t()) :: non_neg_integer 46 | @doc """ 47 | Get integer serial number of the certicate 48 | 49 | ``` 50 | iex> Certbot.Certificate.serial(build_certificate()) 51 | 18163034872729040431 52 | ``` 53 | """ 54 | def serial(%Certbot.Certificate{cert: cert}) do 55 | cert |> X509.Certificate.from_der!() |> X509.Certificate.serial() 56 | end 57 | 58 | @doc """ 59 | Get hexadecimal serial number of the certicate 60 | 61 | This is what is visible when inspecting a certificate in the browser 62 | 63 | ## Example 64 | ``` 65 | iex> Certbot.Certificate.hex_serial(build_certificate()) 66 | "FC100FFC200BF62F" 67 | ``` 68 | """ 69 | @spec hex_serial(Certbot.Certificate.t()) :: String.t() 70 | def hex_serial(%Certbot.Certificate{} = cert) do 71 | cert |> serial() |> Integer.to_string(16) 72 | end 73 | 74 | @doc """ 75 | Get DateTime of the date the certificate was given out 76 | 77 | ## Example 78 | ``` 79 | iex> Certbot.Certificate.valid_from(build_certificate()) 80 | ~U[2019-07-09 00:00:00Z] 81 | ``` 82 | """ 83 | @spec valid_from(Certbot.Certificate.t()) :: DateTime.t() 84 | def valid_from(%Certbot.Certificate{cert: cert}) do 85 | validity(:from, cert) |> to_string |> to_datetime 86 | end 87 | 88 | @doc """ 89 | Get DateTime of the date the certificate will be valid until 90 | 91 | ## Example 92 | ``` 93 | iex> Certbot.Certificate.valid_until(build_certificate()) 94 | ~U[2020-07-09 00:00:00Z] 95 | ``` 96 | """ 97 | @spec valid_until(Certbot.Certificate.t()) :: DateTime.t() 98 | def valid_until(%Certbot.Certificate{cert: cert}) do 99 | validity(:until, cert) |> to_string |> to_datetime 100 | end 101 | 102 | # "190710122644Z" 103 | defp to_datetime( 104 | <> <> 105 | <> <> 106 | <> <> 107 | <> <> 108 | <> <> <> <> _rest 109 | ) do 110 | # No century info present, hack to fix 111 | year = String.to_integer(year) + 2000 112 | 113 | {{year, String.to_integer(month), String.to_integer(day)}, 114 | {String.to_integer(hour), String.to_integer(minute), String.to_integer(second)}} 115 | |> NaiveDateTime.from_erl!() 116 | |> DateTime.from_naive!("Etc/UTC") 117 | end 118 | 119 | defp validity(:from, cert) do 120 | {:Validity, {:utcTime, from}, _} = validity(cert) 121 | from 122 | end 123 | 124 | defp validity(:until, cert) do 125 | {:Validity, _, {:utcTime, until}} = validity(cert) 126 | until 127 | end 128 | 129 | defp validity(cert) do 130 | cert |> X509.Certificate.from_der!() |> X509.Certificate.validity() 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/certbot/certificate_store.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.CertificateStore do 2 | @moduledoc """ 3 | Stores and look ups certificates by hostname 4 | 5 | Behaviour that can be reimplemented for other storage mechanisms, e.g. redis, 6 | database or mnesia 7 | 8 | """ 9 | @callback find_certificate(hostname :: String.t()) :: 10 | Certbot.Certificate.t() | nil 11 | 12 | @callback insert(hostname :: String.t(), certificate :: Certbot.Certificate.t()) :: 13 | :ok 14 | end 15 | -------------------------------------------------------------------------------- /lib/certbot/certificate_store/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.CertificateStore.Default do 2 | @moduledoc """ 3 | Default store for certificates. Stores certificates in an ets table. 4 | 5 | This store won't work when you have multiple servers as the certificates will 6 | only be stored on one server. 7 | """ 8 | @behaviour Certbot.CertificateStore 9 | 10 | use GenServer 11 | 12 | @table :certbot_certificate_store 13 | 14 | def start_link(_opts) do 15 | GenServer.start_link(__MODULE__, @table, name: __MODULE__) 16 | end 17 | 18 | @impl true 19 | def init(table) do 20 | table = 21 | :ets.new(table, [ 22 | :named_table, 23 | :set, 24 | :public, 25 | read_concurrency: true, 26 | write_concurrency: true 27 | ]) 28 | 29 | {:ok, table} 30 | end 31 | 32 | @spec find_certificate(String.t()) :: {:ok, Certbot.Challenge.t()} | {:error, String.t()} 33 | @impl true 34 | def find_certificate(hostname) do 35 | case :ets.lookup(@table, hostname) do 36 | [{^hostname, certificate}] -> certificate 37 | _ -> nil 38 | end 39 | end 40 | 41 | @impl true 42 | def insert(hostname, certificate) do 43 | :ets.insert(@table, {hostname, certificate}) 44 | :ok 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/certbot/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Config do 2 | @moduledoc """ 3 | Configuration for Certbot 4 | 5 | These are the options supplied to `use Certbot, server: ""` 6 | 7 | - `:jwk` -- jwk for the account private key for the Acme client 8 | - `:email` -- email for the account for the Acme client e.g. `mailto:test@example.com` 9 | - `:certificate_provider` -- module that will responsible for getting certificates 10 | from the store, or requesting new certifictes 11 | - `:server` -- Server implementing the Acme protocol, most likely that you want the 12 | staging server `https://acme-staging.api.letsencrypt.org/` or the production server 13 | `https://acme-v01.api.letsencrypt.org`. Alternatively you can use another implemntation 14 | like Boulder or Pebble 15 | 16 | The jwk can be generated from a private key like this: 17 | ```elixir 18 | jwk = "priv/cert/selfsigned_key.pem" 19 | |> File.read!() 20 | |> JOSE.JWK.from_pem() 21 | |> JOSE.JWK.to_map() 22 | 23 | """ 24 | @type t :: %__MODULE__{ 25 | jwk: any(), 26 | server: String.t(), 27 | email: String.t(), 28 | logger: any(), 29 | certificate_provider: any() 30 | } 31 | 32 | defstruct [ 33 | :jwk, 34 | :server, 35 | :email, 36 | :logger, 37 | :certificate_provider 38 | ] 39 | 40 | @spec new(nil | keyword) :: Certbot.Config.t() 41 | def new(opts \\ []) do 42 | %__MODULE__{ 43 | jwk: validate_jwk!(opts[:jwk]), 44 | server: opts[:server] || "https://acme-v01.api.letsencrypt.org/", 45 | email: Keyword.fetch!(opts, :email), 46 | logger: opts[:logger] || Certbot.Logger, 47 | certificate_provider: Keyword.fetch!(opts, :certificate_provider) 48 | } 49 | end 50 | 51 | defp validate_jwk!({_, jwk}) do 52 | %JOSE.JWK{} = JOSE.JWK.from_map(jwk) 53 | jwk 54 | rescue 55 | _ -> reraise ArgumentError, "Invalid jwk supplied to `Certbot.Acme.Plug`" 56 | end 57 | 58 | defp validate_jwk!(jwk) do 59 | raise ArgumentError, "Invalid jwk `#{inspect(jwk)}` supplied to `Certbot.Acme.Plug`" 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/certbot/error.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Error do 2 | @moduledoc """ 3 | Struct to store errors 4 | """ 5 | defstruct [:detail, :status, :type] 6 | 7 | @doc """ 8 | Convert structs of the same shape, to a Certbot.Error struct. 9 | 10 | E.g. Acme.Error 11 | """ 12 | def from_struct(map) do 13 | map = Map.from_struct(map) 14 | struct(__MODULE__, map) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/certbot/logger.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Logger do 2 | @moduledoc """ 3 | Module and behaviour to log events 4 | """ 5 | 6 | require Logger 7 | @callback log(:debug | :error | :info | :warn, any) :: :ok | {:error, any} 8 | @spec log(:debug | :error | :info | :warn, any) :: :ok | {:error, any} 9 | def log(level, chardata_or_fun), do: Logger.log(level, chardata_or_fun) 10 | end 11 | -------------------------------------------------------------------------------- /lib/certbot/provider.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Provider do 2 | @moduledoc """ 3 | Behaviour used for providing certficates by a given hostname. 4 | """ 5 | @callback get_by_hostname(hostname :: String.t()) :: Certbot.Certificate.t() | nil 6 | end 7 | -------------------------------------------------------------------------------- /lib/certbot/provider/acme.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Provider.Acme do 2 | @moduledoc """ 3 | Certificate provider for the Acme protocol 4 | 5 | When a request is made for a hostname, the provider will look into the 6 | certificate store (`Certbot.CertificateStore`) to see whether it has a 7 | certificate for that hostname. 8 | 9 | If so, it will return the certificate. 10 | 11 | If not, it will try to request a certificate using the acme client. This is done 12 | by retrieving an authorization, which has challenges. We need to prove to the acme 13 | server that we own the hostname. 14 | 15 | One of these challenges can be done over http. We use this one to prove ownership. 16 | The challenge is stored in the challenge store (`Certbot.Acme.ChallengeStore`), 17 | then the Acme server is asked to verify the challenge. The `Certbot.Acme.Plug` 18 | verifies the challenge by using the store. 19 | 20 | Next step is to build a Certificate Signing Request (`csr`) and send this to 21 | the Acme server. In the response there will be a url where the signed certificate 22 | can be retrieved from the Acme server. 23 | 24 | The downloaded certificate is used for the serving the request, and also stored 25 | in the certificate store for subsequent requests. 26 | 27 | ## Example 28 | ``` 29 | use Certbot.Provider.Acme, 30 | acme_client: YourApp.Certbot, 31 | certificate_store: Certbot.CertificateStore.Default, 32 | challenge_store: Certbot.ChallengeStore.Default 33 | ``` 34 | 35 | For the options that can be given to the `use` macro, see `Certbot.Provider.Acme.Config` 36 | """ 37 | defmodule Config do 38 | @moduledoc """ 39 | Configuration for the `Certbot.Provider.Acme` certificate provider. 40 | 41 | - `:acme_client` -- Client implementing `use Certbot`, e.g. `Myapp.Certbot` 42 | - `:certificate_store` -- Module used to store certificates, 43 | - `:challenge_store` -- Module used to store certificates, 44 | - `:logger` -- Module to log events, defaults to `Certbot.Logger`, 45 | - `:key_algorithm` -- Algorithm used to generate keys for certificates, 46 | defaults to `{:ec, :secp384r1}`. Can also be e.g. `{:rsa, 2048}` 47 | 48 | """ 49 | defstruct [:certificate_store, :challenge_store, :acme_client, :logger, :key_algorithm] 50 | 51 | def new(opts \\ []) do 52 | %__MODULE__{ 53 | acme_client: Keyword.fetch!(opts, :acme_client), 54 | certificate_store: Keyword.fetch!(opts, :certificate_store), 55 | challenge_store: opts[:challenge_store], 56 | logger: opts[:logger] || Certbot.Logger, 57 | key_algorithm: opts[:key_algorithm] || {:ec, :secp384r1} 58 | } 59 | end 60 | end 61 | 62 | defmacro __using__(opts) do 63 | quote location: :keep do 64 | @defaults unquote(opts) 65 | 66 | @behaviour Certbot.Provider 67 | 68 | alias Certbot.Provider.Acme 69 | 70 | def get_by_hostname(hostname, opts \\ []) do 71 | opts = Keyword.merge(@defaults, opts) 72 | 73 | Acme.get_by_hostname(hostname, opts) 74 | end 75 | end 76 | end 77 | 78 | alias Certbot.Acme.Authorization 79 | alias Certbot.Provider.Acme.Config 80 | 81 | def get_by_hostname(hostname, opts) do 82 | config = Config.new(opts) 83 | config.logger.log(:info, "Checking store for certificate for #{hostname}") 84 | 85 | case config.certificate_store.find_certificate(hostname) do 86 | %Certbot.Certificate{} = certificate -> 87 | serial = Certbot.Certificate.hex_serial(certificate) 88 | config.logger.log(:info, "Found certificate (#{serial}) for #{hostname} in store") 89 | certificate 90 | 91 | _ -> 92 | config.logger.log( 93 | :info, 94 | "No certificate found in store, requesting certificate for #{hostname}" 95 | ) 96 | 97 | case authorize_hostname(hostname, config) do 98 | {:ok, certificate} -> 99 | serial = Certbot.Certificate.hex_serial(certificate) 100 | 101 | config.logger.log( 102 | :info, 103 | "Retrieved certificate (#{serial}) for #{hostname}, storing it" 104 | ) 105 | 106 | config.certificate_store.insert(hostname, certificate) 107 | 108 | certificate 109 | 110 | {:error, error} -> 111 | config.logger.log(:error, inspect(error)) 112 | error 113 | end 114 | end 115 | end 116 | 117 | defp authorize_hostname(hostname, config) do 118 | case config.acme_client.authorize(hostname) do 119 | {:ok, authorization} -> 120 | challenge = Authorization.fetch_challenge(authorization, "http-01") 121 | config.logger.log(:info, "Storing challenge in store for #{hostname}") 122 | config.challenge_store.insert(challenge) 123 | 124 | check_challenge(challenge, hostname, config) 125 | 126 | {:error, error} -> 127 | {:error, error} 128 | end 129 | end 130 | 131 | defp check_challenge(challenge, hostname, config) do 132 | config.logger.log(:info, "Checking challenge #{challenge.uri} for #{hostname}") 133 | 134 | case config.acme_client.respond_challenge(challenge) do 135 | {:ok, %{status: "valid"}} -> 136 | get_certificate(hostname, config) 137 | 138 | # should validate edge cases here 139 | # the 10ms is completely arbitrary... 140 | {:ok, _challenge_response} -> 141 | Process.sleep(10) 142 | 143 | check_challenge(challenge, hostname, config) 144 | 145 | {:error, error} -> 146 | config.logger.error(inspect(error)) 147 | 148 | nil 149 | end 150 | end 151 | 152 | defp get_certificate(hostname, config) do 153 | key = Certbot.SSL.generate_key(config.key_algorithm) 154 | algorithm = elem(key, 0) 155 | csr = Certbot.SSL.generate_csr(key, %{common_name: hostname}) 156 | 157 | der_key = Certbot.SSL.convert_private_key_to_der(key) 158 | 159 | with {:ok, url} <- config.acme_client.new_certificate(csr), 160 | {:ok, certificate} <- config.acme_client.get_certificate(url) do 161 | certificate = Certbot.Certificate.build(certificate, {algorithm, der_key}) 162 | 163 | {:ok, certificate} 164 | else 165 | error -> error 166 | end 167 | end 168 | end 169 | -------------------------------------------------------------------------------- /lib/certbot/provider/static.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Provider.Static do 2 | @moduledoc """ 3 | Static certificate provider 4 | 5 | Expects a `certificates` keyword with a map of hostnames as keys and 6 | `%Certbot.Certificate{}` structs as values. 7 | 8 | Also an example of a simple certificate provider. 9 | 10 | ```elixir 11 | defmodule Myapp.StaticProvider do 12 | use Certbot.Provider.Acme, 13 | certificates: %{ 14 | "example.com" => %Certbot.Certificate{ 15 | cert: cert_der, 16 | key: {:RSAPrivateKey, key_der} 17 | } 18 | } 19 | end 20 | ``` 21 | """ 22 | 23 | defmacro __using__(opts) do 24 | quote location: :keep do 25 | @behaviour Certbot.Provider 26 | @defaults unquote(opts) 27 | 28 | alias Certbot.Provider.Static 29 | 30 | def get_by_hostname(hostname, opts \\ []) do 31 | opts = Keyword.merge(@defaults, opts) 32 | 33 | Static.get_by_hostname(hostname, opts) 34 | end 35 | end 36 | end 37 | 38 | @spec get_by_hostname(binary, keyword) :: nil | Certbot.Certificate.t() 39 | def get_by_hostname(hostname, opts \\ []) do 40 | certificates = Keyword.fetch!(opts, :certificates) 41 | 42 | case Map.get(certificates, hostname) do 43 | %Certbot.Certificate{} = certificate -> certificate 44 | _ -> nil 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/certbot/ssl.ex: -------------------------------------------------------------------------------- 1 | defmodule Certbot.SSL do 2 | @moduledoc """ 3 | Utility functions to deal with ssl and generating private keys 4 | 5 | """ 6 | @subject_keys %{ 7 | common_name: "CN", 8 | country_name: "C", 9 | locality_name: "L", 10 | organization_name: "O", 11 | organizational_unit: "OU", 12 | state_or_province: "ST" 13 | } 14 | 15 | @rsa_key_sizes [2048, 3072, 4096] 16 | # https://tools.ietf.org/search/rfc4492#appendix-A 17 | @ec_curves [:secp256r1, :secp384r1] 18 | 19 | def generate_key({:rsa, size}) when size in @rsa_key_sizes do 20 | X509.PrivateKey.new_rsa(size) 21 | end 22 | 23 | def generate_key({:ec, curve}) when curve in @ec_curves do 24 | X509.PrivateKey.new_ec(curve) 25 | end 26 | 27 | def convert_private_key_to_der(key) do 28 | X509.PrivateKey.to_der(key) 29 | end 30 | 31 | def convert_private_key_to_pem(key) do 32 | X509.PrivateKey.to_pem(key) 33 | end 34 | 35 | def generate_csr(private_key, subject) do 36 | private_key |> X509.CSR.new(format_subject(subject)) |> X509.CSR.to_der() 37 | end 38 | 39 | defp format_subject(subject) do 40 | subject 41 | |> Enum.map(fn {k, v} -> "/#{@subject_keys[k]}=#{v}" end) 42 | |> Enum.join() 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Certbot.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.5.1" 5 | def project do 6 | [ 7 | app: :certbot, 8 | version: @version, 9 | elixir: "~> 1.9", 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | name: "Certbot", 13 | source_url: "https://github.com/maartenvanvliet/certbot", 14 | homepage_url: "https://github.com/maartenvanvliet/certbot", 15 | description: 16 | "Provide dynamic ssl-certificates for your Phoenix or Plug app using Letsencrypt", 17 | package: [ 18 | maintainers: ["Maarten van Vliet"], 19 | licenses: ["MIT"], 20 | links: %{"GitHub" => "https://github.com/maartenvanvliet/certbot"}, 21 | files: ~w(LICENSE README.md lib mix.exs) 22 | ], 23 | docs: [ 24 | main: "Certbot", 25 | canonical: "http://hexdocs.pm/certbot", 26 | source_url: "https://github.com/maartenvanvliet/certbot", 27 | nest_modules_by_prefix: [Certbot.Acme] 28 | ] 29 | ] 30 | end 31 | 32 | # Run "mix help compile.app" to learn about applications. 33 | def application do 34 | [ 35 | extra_applications: [:logger] 36 | ] 37 | end 38 | 39 | # Run "mix help deps" to learn about dependencies. 40 | defp deps do 41 | [ 42 | {:acme, "~> 0.5.1"}, 43 | {:x509, "~> 0.8.0"}, 44 | {:ex_doc, "~> 0.27.3", only: :dev}, 45 | {:plug, "~> 1.7"}, 46 | {:jose, "~> 1.9"}, 47 | {:credo, "~> 1.5.0", only: [:dev, :test], runtime: false} 48 | ] 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "acme": {:hex, :acme, "0.5.1", "8936cb3719ddaaeb36698b3a4a4dc05feb636d0369a2ebef972fca18f031a25a", [:mix], [{:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: false]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:poison, "~> 3.1", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "207ad4effac72f552722484690f880df733b7bbfcc6da2fbc92b7c6088d747e0"}, 3 | "base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"}, 4 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 5 | "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"}, 6 | "credo": {:hex, :credo, "1.5.2", "5562f1a1693f77e7319fdabac6d17d26de7e6b0a2b57743bca24a89469232f04", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7003506f069866a4e5d6216a7216823b00ed4bcc4bd9c6e449fa6625c411649b"}, 7 | "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.19", "de0d033d5ff9fc396a24eadc2fcf2afa3d120841eb3f1004d138cbf9273210e8", [:mix], [], "hexpm", "527ab6630b5c75c3a3960b75844c314ec305c76d9899bb30f71cb85952a9dc45"}, 9 | "ex_doc": {:hex, :ex_doc, "0.27.3", "d09ed7ab590b71123959d9017f6715b54a448d76b43cf909eb0b2e5a78a977b2", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "ee60b329d08195039bfeb25231a208749be4f2274eae42ce38f9be0538a2f2e6"}, 10 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 11 | "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "c2790c9f0f7205f4a362512192dee8179097394400e745e4d20bab7226a8eaad"}, 12 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 13 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 14 | "jose": {:hex, :jose, "1.11.0", "b1e6145881c97f489a26c19e117be014edcd1eac71deedce09ebb3a529569578", [:mix, :rebar3], [], "hexpm", "35739462122a4d073519643e55d582375f4c43192d1bcd240357d101b80b2b34"}, 15 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 19 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"}, 20 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.0", "b44d75e2a6542dcb6acf5d71c32c74ca88960421b6874777f79153bbbbd7dccc", [:mix], [], "hexpm", "52b2871a7515a5ac49b00f214e4165a40724cf99798d8e4a65e4fd64ebd002c1"}, 22 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 23 | "plug": {:hex, :plug, "1.10.1", "c56a6d9da7042d581159bcbaef873ba9d87f15dce85420b0d287bca19f40f9bd", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "b5cd52259817eb8a31f2454912ba1cff4990bca7811918878091cb2ab9e52cb8"}, 24 | "plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"}, 25 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm", "fec8660eb7733ee4117b85f55799fd3833eb769a6df71ccf8903e8dc5447cfce"}, 26 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm", "603561dc0fd62f4f2ea9b890f4e20e1a0d388746d6e20557cafb1b16950de88c"}, 27 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 28 | "x509": {:hex, :x509, "0.8.1", "901a72cb715aeb2f21739425cc0cff97ce975b6267811acb1fcee4ff10e7799c", [:mix], [], "hexpm", "30c6d9376c90c74490c80befea53cc0d07f05d48bb4ce9e472573a06b0aae008"}, 29 | } 30 | -------------------------------------------------------------------------------- /test/certbot/acme/authorization_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Acme.AuthorizationTest do 2 | use ExUnit.Case 3 | doctest Certbot.Acme.Authorization 4 | end 5 | -------------------------------------------------------------------------------- /test/certbot/acme/challenge_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Acme.ChallengeTest do 2 | use ExUnit.Case 3 | 4 | doctest Certbot.Acme.Challenge 5 | end 6 | -------------------------------------------------------------------------------- /test/certbot/acme/plug_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Acme.PlugTest do 2 | use ExUnit.Case 3 | 4 | alias Certbot.Acme 5 | 6 | defmodule TestChallengeStore do 7 | @behaviour Certbot.Acme.ChallengeStore 8 | 9 | @impl true 10 | def find_by_token("some-token") do 11 | {:ok, 12 | %Certbot.Acme.Challenge{ 13 | token: "some-token" 14 | }} 15 | end 16 | 17 | def find_by_token(_) do 18 | nil 19 | end 20 | 21 | @impl true 22 | def insert(_challenge) do 23 | :ok 24 | end 25 | end 26 | 27 | test "returns thumbprint for known token" do 28 | conn = test_token_conn("some-token") 29 | assert conn.status == 200 30 | assert conn.resp_body == "some-token.v5Co8pJG2fo_hBcdhEzpj_DSEcev76KkbFQkJRiu-Cg" 31 | end 32 | 33 | test "returns 404 for unknown token" do 34 | conn = test_token_conn("unknown-token") 35 | assert conn.status == 404 36 | assert conn.resp_body == "Not found" 37 | end 38 | 39 | test "passes plug for other paths" do 40 | conn = test_token_conn("unknown-token", "/other-path") 41 | assert conn.halted == false 42 | end 43 | 44 | defp test_token_conn(token, path \\ "/.well-known/acme-challenge/") do 45 | jwk = 46 | "test/fixtures/selfsigned_key.pem" 47 | |> File.read!() 48 | |> JOSE.JWK.from_pem() 49 | |> JOSE.JWK.to_map() 50 | 51 | opts = Acme.Plug.init(jwk: jwk, challenge_store: TestChallengeStore) 52 | 53 | Plug.Test.conn(:get, path <> token) |> Acme.Plug.call(opts) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/certbot/certificate_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Certbot.CertificateTest do 2 | use ExUnit.Case 3 | doctest Certbot.Certificate 4 | 5 | def build_certificate do 6 | Helper.build_certificate() 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/certbot/provider/acme_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Provider.AcmeTest do 2 | use ExUnit.Case 3 | doctest Certbot.Provider.Acme 4 | 5 | alias Certbot.Provider.AcmeTest 6 | 7 | defmodule TestAcme do 8 | use Certbot.Provider.Acme, 9 | certificate_store: AcmeTest.TestCertificateStore, 10 | acme_client: AcmeTest.TestClient, 11 | challenge_store: AcmeTest.TestChallengeStore, 12 | logger: NoopLogger 13 | end 14 | 15 | defmodule TestClient do 16 | def authorize("bogus.com") do 17 | authorization = %Certbot.Acme.Authorization{ 18 | challenges: [ 19 | %Certbot.Acme.Challenge{ 20 | status: "pending", 21 | token: "some_token", 22 | type: "http-01", 23 | uri: nil 24 | } 25 | ] 26 | } 27 | 28 | {:ok, authorization} 29 | end 30 | 31 | def respond_challenge(_challenge) do 32 | {:ok, 33 | %Certbot.Acme.Challenge{ 34 | status: "valid", 35 | token: "some_token", 36 | type: "http-01", 37 | uri: nil 38 | }} 39 | end 40 | 41 | def new_certificate(_csr) do 42 | {:ok, "http://example.com/certificate"} 43 | end 44 | 45 | def get_certificate(url) do 46 | send(self(), url) 47 | {:ok, Helper.der_cert()} 48 | end 49 | end 50 | 51 | defmodule TestCertificateStore do 52 | @behaviour Certbot.CertificateStore 53 | 54 | @impl true 55 | def find_certificate("test.com"), do: Helper.build_certificate() 56 | 57 | def find_certificate("bogus.com"), do: nil 58 | 59 | @impl true 60 | def insert(hostname, certificate) do 61 | send(self(), {:insert_certificate, hostname, certificate}) 62 | end 63 | end 64 | 65 | defmodule TestChallengeStore do 66 | @behaviour Certbot.Acme.ChallengeStore 67 | 68 | @impl true 69 | def find_by_token(_), do: nil 70 | 71 | @impl true 72 | def insert(challenge) do 73 | send(self(), challenge) 74 | end 75 | end 76 | 77 | test "returns certificate for already stored hostname" do 78 | assert %Certbot.Certificate{} = certificate = TestAcme.get_by_hostname("test.com") 79 | assert certificate == Helper.build_certificate() 80 | end 81 | 82 | test "returns nil for unknown hostname" do 83 | assert %Certbot.Certificate{} = TestAcme.get_by_hostname("bogus.com") 84 | 85 | assert_received(%Certbot.Acme.Challenge{ 86 | status: "pending", 87 | token: "some_token", 88 | type: "http-01", 89 | uri: nil 90 | }) 91 | 92 | assert_received("http://example.com/certificate") 93 | 94 | assert_received({:insert_certificate, "bogus.com", inserted_certificate}) 95 | assert %Certbot.Certificate{} = inserted_certificate 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/certbot/provider/static_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Certbot.Provider.StaticTest do 2 | use ExUnit.Case 3 | doctest Certbot.Provider.Static 4 | 5 | defmodule TestStatic do 6 | use Certbot.Provider.Static, 7 | certificates: %{ 8 | "test.com" => Helper.build_certificate() 9 | } 10 | end 11 | 12 | test "returns certificate for known hostname" do 13 | assert %Certbot.Certificate{} = certificate = TestStatic.get_by_hostname("test.com") 14 | assert certificate == Helper.build_certificate() 15 | end 16 | 17 | test "returns nil for unknown hostname" do 18 | assert nil == TestStatic.get_by_hostname("bogus.com") 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/certbot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CertbotTest do 2 | use ExUnit.Case 3 | 4 | defmodule TestProvider do 5 | def get_by_hostname("example.com") do 6 | Helper.build_certificate() 7 | end 8 | 9 | def get_by_hostname("unknown.com") do 10 | nil 11 | end 12 | end 13 | 14 | defmodule TestStaticClient do 15 | @jwk "test/fixtures/selfsigned_key.pem" 16 | |> File.read!() 17 | |> JOSE.JWK.from_pem() 18 | |> JOSE.JWK.to_map() 19 | 20 | use Certbot, 21 | jwk: @jwk, 22 | email: "mailto:test@example.com", 23 | certificate_provider: CertbotTest.TestProvider, 24 | logger: NoopLogger 25 | end 26 | 27 | test "returns certificate/key as list for known hostname" do 28 | cert = Helper.build_certificate() 29 | assert [cert: cert.cert, key: cert.key] == TestStaticClient.sni_fun('example.com') 30 | end 31 | 32 | test "returns empty list for unknown hostname" do 33 | assert [] == TestStaticClient.sni_fun('unknown.com') 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/fixtures/selfsigned.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDfTCCAmWgAwIBAgIJAPwQD/wgC/YvMA0GCSqGSIb3DQEBCwUAMEMxGjAYBgNV 3 | BAoMEVBob2VuaXggRnJhbWV3b3JrMSUwIwYDVQQDDBxTZWxmLXNpZ25lZCB0ZXN0 4 | IGNlcnRpZmljYXRlMB4XDTE5MDcwOTAwMDAwMFoXDTIwMDcwOTAwMDAwMFowQzEa 5 | MBgGA1UECgwRUGhvZW5peCBGcmFtZXdvcmsxJTAjBgNVBAMMHFNlbGYtc2lnbmVk 6 | IHRlc3QgY2VydGlmaWNhdGUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB 7 | AQDcNodg7WNJjFH+t42M7VPUJMi0jAu6XzW57fjIBtbkq0M0eQyvidlqSbGJWX/e 8 | I6yToqpRQsM+ZB2f9YfkXotiRMPT7WzXCAdQWP+ID0Nmkuu9bNcPWF2IEzF6B8xG 9 | K4gU7SS+0IdHEpR+6pFcAqc29kTbZp2HmnuhNcqz4NsWPzHZ7Ih27tMYbgGZtILc 10 | GKOlVZFe59GxNO+ixgtiiocn0BpLXe3tzkJvK/NHjgWd7W/fz8YzwgtfCSrHk/ur 11 | Taw2beOCeWQfku7dtkxqDj8EiSR3m6krHQbNx7HnALhRggMU1uUPgVXVl9hHKUEw 12 | 3Q8p/3hv+ug3qGyG8vhF1i4VAgMBAAGjdDByMAwGA1UdEwEB/wQCMAAwDgYDVR0P 13 | AQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAdBgNVHQ4E 14 | FgQU+DjvS8hlTvnnKifO7MRL2vK65UwwFAYDVR0RBA0wC4IJbG9jYWxob3N0MA0G 15 | CSqGSIb3DQEBCwUAA4IBAQBpLQ/nDsSR0rugoP5SzH2OHuucqlBaSE0YBLvMIf+q 16 | /TQTa1n6seEDjuXzrjsiHkkgd5mEctdCJD8shL88Tqzmx7bQS0kWAQ9H5t2Xg57J 17 | UjqnTEW0OJn9ph3CvMGfHods0PV0Z4iFnbuSUdQ6oDCPzswfQ00VQwcp7+3LdWtq 18 | DpHezHk+btS1KNmTwEhgeVJT2l9LEG+6eVwhBNeY7vcTBK6eISJjFzuqUPZDXZhX 19 | ViJ+JA8/1udZ43PTAgG7S9e5yzpANoSHDqEjkLUoJOb5epHa/26QKJO/Z2lb/byT 20 | 1HlWIxHoImVmJJ5vDv3YuwsKpScVUHblrILYpyg8SLCO 21 | -----END CERTIFICATE----- 22 | 23 | -------------------------------------------------------------------------------- /test/fixtures/selfsigned_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEA3DaHYO1jSYxR/reNjO1T1CTItIwLul81ue34yAbW5KtDNHkM 3 | r4nZakmxiVl/3iOsk6KqUULDPmQdn/WH5F6LYkTD0+1s1wgHUFj/iA9DZpLrvWzX 4 | D1hdiBMxegfMRiuIFO0kvtCHRxKUfuqRXAKnNvZE22adh5p7oTXKs+DbFj8x2eyI 5 | du7TGG4BmbSC3BijpVWRXufRsTTvosYLYoqHJ9AaS13t7c5CbyvzR44Fne1v38/G 6 | M8ILXwkqx5P7q02sNm3jgnlkH5Lu3bZMag4/BIkkd5upKx0Gzcex5wC4UYIDFNbl 7 | D4FV1ZfYRylBMN0PKf94b/roN6hshvL4RdYuFQIDAQABAoIBAB3um4AlRDWfCRYi 8 | RO8+4wIW7eD8mCuA/YCERCiMJXF3he7/9SV3C0JTOfp9W9AJ8U8v8Q6SkI9OdGhl 9 | q07zOubkiemof/7KbJQTRMhtqq+qkLhyrti7Hht84GDB5pYzHJAbm78EAR87+0s0 10 | /wUOp0PlPX7E9+ySpvNGqILCsYTYWW/ris7Vjg+S7SdEr0in8UX/slWjbOqNUGA3 11 | FlanYRx3WW/Rclgi1s11VZRjTGXsG+1+2wuPMFQElO9YkslNGDAvmzRK+G55rA4j 12 | TfH0oU9IDlroqobfWsU3xEf7yUX7SYp3HVFg46xL+aZNPG9Gb/C6duv9jZ1IDhg4 13 | 9kDC7oECgYEA/jYDOuF1ywo77BIiIHrv56hmmdLf/ZqXB5wSh84kYGVnBfSWoXQI 14 | 6DfRPpSB7O6PlWOWlIREh+5d39elRc5/cW3lq51EmNHc7boXaz49bogcvpu6JdR2 15 | RFWyRMV/HAHxcB4R0j8LVf9BfSEX+KhWHcqDbdJC62RqPFMvMQck5w0CgYEA3cND 16 | 7CaIaNA1KruXtrdlX71znO0ADZ92KdmntyS6/kzqWMKFe8pvDaYqOTDHyrY/zC9a 17 | 3BdX7VWhCNdndu3v3jWg1fVaIg0pdZeC8ZK9SySs1uMmrGPRIUnqKTNv3ymdO4ym 18 | RbloLL/uf2iK0ybZgGX3vaRfUBWjP/FZYDKWoSkCgYA+/0GjqNXZIEsjQIcmh3DG 19 | duweOKz7mwDMiPfqocJQBTEXv7pIfonqilKXcJQWFDSO7+QUIAcfrImk/Drf5sGc 20 | aYCeG9YxDOj0HMbK89yjdKWy8sKZt2IroxUPh+XtGeosP7do4+i6QgyHptja7VSS 21 | A2q4n3+n9/V/x5mNS9jwTQKBgHOK437M7NG/eZQUPY3DrBvf97bRfO+cH9LaRpoT 22 | lyIcLRWl9Cp1ZLs10lYN5mrl3gOiwLJfrz5HGSokIRJEEnAyfOQ9e4K2XN2Z3W3E 23 | SMA4EZ28qE+1iibP/iMNW8JoSjXWqGM3oOF/9uGHNJ2jZjuR5Sx30flF7NLKCwkm 24 | EnlRAoGBAKZPWP55BrCOqbb9ERZSZLGDkliDkv/8HEbSAjboQxuGobIYh8lt9lpt 25 | Y6sfeHJf9zrIMjoCd4fKQnWVCEn+q799ktvxHhrm/7oesF36XN6MLW/wGlaKBFDx 26 | qqPQMdC7SrWGPewTs0CUa0Q+vWmWD2fOoWS+ZWCYiPXIQztewXXF 27 | -----END RSA PRIVATE KEY----- 28 | 29 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | defmodule Helper do 4 | def build_certificate do 5 | key_file = File.read!("test/fixtures/selfsigned_key.pem") 6 | 7 | [key] = :public_key.pem_decode(key_file) 8 | key = :public_key.pem_entry_decode(key) 9 | der_key = :public_key.der_encode(:RSAPrivateKey, key) 10 | 11 | Certbot.Certificate.build(der_cert(), {:RSAPrivateKey, der_key}) 12 | end 13 | 14 | def der_cert do 15 | cert_file = File.read!("test/fixtures/selfsigned.pem") 16 | [certificate] = :public_key.pem_decode(cert_file) 17 | cert = :public_key.pem_entry_decode(certificate) 18 | :public_key.der_encode(:Certificate, cert) 19 | end 20 | end 21 | 22 | defmodule NoopLogger do 23 | @behaviour Certbot.Logger 24 | def log(_, _), do: nil 25 | end 26 | --------------------------------------------------------------------------------