Skip to content

Commit

Permalink
Encrypt API tokens (#2360)
Browse files Browse the repository at this point in the history
Store API tokens encrypted in the database.

During the database migration a randomly generated key will be used encrypt the tokens if no ENCRYPTION_KEY environment variable was provided.

If the application is started without the presence of an ENCRYPTION_KEY (or if the key failed to decrypt the existing tokens), the UI will display a warning with further instructions.
  • Loading branch information
adriankumpf authored Feb 18, 2022
1 parent d461292 commit 0d6e288
Show file tree
Hide file tree
Showing 37 changed files with 2,629 additions and 1,867 deletions.
6 changes: 6 additions & 0 deletions assets/css/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,12 @@ nav.navbar {
}
}

.notification {
.mdi {
font-size: 1em;
}
}

.mdi {
font-size: 1.25em;
}
Expand Down
6 changes: 6 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ config :teslamate, TeslaMateWeb.Endpoint,
pubsub_server: TeslaMate.PubSub,
live_view: [signing_salt: "6nSVV0NtBtBfA9Mjh+7XaZANjp9T73XH"]

config :teslamate,
cloak_repo: TeslaMate.Repo,
cloak_schemas: [
TeslaMate.Auth.Tokens
]

config :logger, :console,
format: "$time $metadata[$level] $message\n",
metadata: [:car_id]
Expand Down
2 changes: 2 additions & 0 deletions config/runtime.exs
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,5 @@ if config_env() != :test do
end

config :teslamate, :srtm_cache, System.get_env("SRTM_CACHE", ".srtm_cache")

config :teslamate, TeslaMate.Vault, key: Util.get_env("ENCRYPTION_KEY", test: "secret")
4 changes: 4 additions & 0 deletions lib/teslamate/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ defmodule TeslaMate.Api do

state

%Tokens{access: :error, refresh: :error} ->
Logger.warning("Could not decrypt API tokens!")
state

_ ->
state
end
Expand Down
2 changes: 2 additions & 0 deletions lib/teslamate/application.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ defmodule TeslaMate.Application do
nil ->
[
TeslaMate.Repo,
TeslaMate.Vault,
TeslaMate.HTTP,
TeslaMate.Api,
TeslaMate.Updater,
Expand All @@ -36,6 +37,7 @@ defmodule TeslaMate.Application do
import_directory ->
[
TeslaMate.Repo,
TeslaMate.Vault,
TeslaMate.HTTP,
TeslaMate.Api,
TeslaMate.Updater,
Expand Down
14 changes: 13 additions & 1 deletion lib/teslamate/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ defmodule TeslaMate.Auth do
"""

import Ecto.Query, warn: false
require Logger

alias TeslaMate.Repo

### Tokens
Expand All @@ -14,9 +16,19 @@ defmodule TeslaMate.Auth do
%Tokens{} |> Tokens.changeset(attrs)
end

def can_decrypt_tokens? do
case get_tokens() do
%Tokens{} = tokens ->
is_binary(tokens.access) and is_binary(tokens.refresh)

nil ->
true
end
end

def get_tokens do
case Repo.all(Tokens) do
[tokens] ->
[%Tokens{} = tokens] ->
tokens

[_ | _] = tokens ->
Expand Down
7 changes: 5 additions & 2 deletions lib/teslamate/auth/tokens.ex
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
defmodule TeslaMate.Auth.Tokens do
use Ecto.Schema

import Ecto.Changeset

alias TeslaMate.Vault.Encrypted

schema "tokens" do
field :refresh, :string
field :access, :string
field :refresh, Encrypted.Binary, redact: true
field :access, Encrypted.Binary, redact: true

timestamps()
end
Expand Down
140 changes: 140 additions & 0 deletions lib/teslamate/vault.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
defmodule TeslaMate.Vault do
use Cloak.Vault,
otp_app: :teslamate

defmodule Encrypted.Binary do
use Cloak.Ecto.Binary, vault: TeslaMate.Vault
end

require Logger

# With AES.GCM, 12-byte IV length is necessary for interoperability reasons.
# See https://github.com/danielberkompas/cloak/issues/93
@iv_length 12

@doc """
The default cipher used to encrypt values is AES-265 in GCM mode.
A random IV is generated for every encryption, and prepends the key tag, IV,
and ciphertag to the beginning of the ciphertext:
+----------------------------------------------------------+----------------------+
| HEADER | BODY |
+-------------------+---------------+----------------------+----------------------+
| Key Tag (n bytes) | IV (12 bytes) | Ciphertag (16 bytes) | Ciphertext (n bytes) |
+-------------------+---------------+----------------------+----------------------+
|_________________________________
|
+---------------+-----------------+-------------------+
| Type (1 byte) | Length (1 byte) | Key Tag (n bytes) |
+---------------+-----------------+-------------------+
The `Key Tag` component of the header consists of a `Type`, `Length`, and
`Value` triplet for easy decoding.
For more information see `Cloak.Ciphers.AES.GCM`.
"""
def default_chipher(key) do
{Cloak.Ciphers.AES.GCM, tag: "AES.GCM.V1", key: key, iv_length: @iv_length}
end

def encryption_key_provided? do
case get_encryption_key_from_config() do
{:ok, _key} -> true
:error -> false
end
end

@impl GenServer
def init(config) do
encryption_key =
with :error <- get_encryption_key_from_config(),
:error <- get_encryption_key_from(System.tmp_dir()),
:error <- get_encryption_key_from(import_dir()) do
key_length = 48 + :rand.uniform(16)
random_key = generate_random_key(key_length)

Logger.warning("""
\n------------------------------------------------------------------------------
No ENCRYPTION_KEY was found to encrypt and securly store your API tokens.
Therefore, the following randomly generated key will be used instead for this
session:
#{pad(random_key, 80)}
Create an environment variable named "ENCRYPTION_KEY" with the value set to
the key above (or choose your own) and pass it to the application from now on.
OTHERWISE, A LOGIN WITH YOUR API TOKENS WILL BE REQUIRED AFTER EVERY RESTART!
------------------------------------------------------------------------------
""")

random_key
else
{:ok, key} -> key
end

config =
Keyword.put(config, :ciphers,
default: default_chipher(:crypto.hash(:sha256, encryption_key))
)

{:ok, config}
end

defp pad(string, width) do
case String.length(string) do
len when len < width ->
string
|> String.pad_leading(div(width - len, 2) + len)
|> String.pad_trailing(width)

_ ->
string
end
end

defp get_encryption_key_from_config do
Application.get_env(:teslamate, TeslaMate.Vault)
|> Access.fetch!(:key)
|> case do
key when is_binary(key) and byte_size(key) > 0 -> {:ok, key}
_ -> :error
end
end

# the database migration writes the generated key into a tmp dir and a local
# 'import' dir if possible. The latter is likely a persistent volume for a
# lot of users of the Docker image.
# see priv/migrations/20220123131732_encrypt_api_tokens.exs
defp get_encryption_key_from(dir) do
with dir when is_binary(dir) <- dir,
path = Path.join(dir, "tm_encryption.key"),
{:ok, encryption_key} <- File.read(path) do
Logger.info("""
Restored encryption key from #{path}:
#{encryption_key}
""")

{:ok, encryption_key}
else
_ -> :error
end
end

defp import_dir do
path =
System.get_env("IMPORT_DIR", "import")
|> Path.absname()

if File.exists?(path), do: path
end

defp generate_random_key(length) when length > 31 do
:crypto.strong_rand_bytes(length) |> Base.encode64(padding: false) |> binary_part(0, length)
end
end
17 changes: 17 additions & 0 deletions lib/teslamate_web/templates/layout/root.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,24 @@
<% show_doante_button = @conn.cookies["donate"] in Enum.map(0..1, &to_string/1) %>
<% update = TeslaMate.Updater.get_update() %>


<main role="main" style="overflow: hidden;" class={["section", (if show_doante_button or update, do: " full-height")]}>
<%= if not TeslaMate.Vault.encryption_key_provided?() or
(not TeslaMate.Api.signed_in?() and not TeslaMate.Auth.can_decrypt_tokens?()) do %>
<% docs_link = link "docs.teslamate.org", to: "https://docs.teslamate.org",
target: "_blank",
rel: "noopener noreferrer" %>

<div class="notification is-warning">
<p class="title is-5 is-spaced">
<span class="icon"><i class="mdi mdi-shield-alert"></i></span>&nbsp;<%= gettext "No encryption key provided" %>
</p>
<p class="subtitle is-6"><%= raw(gettext "To ensure that your <strong>Tesla API tokens are stored securely</strong>, an encryption key must be provided to TeslaMate via the <code>ENCRYPTION_KEY</code> environment variable. Otherwise, a <strong>login will be required after every restart</strong>.") %></p>
<p class="subtitle is-6"><%= raw(gettext "The automatically generated encryption key used for the current session can be found <strong>in the application logs</strong>.") %></p>
<p class="subtitle is-6"><%= raw(gettext "For more information, see the updated installation guides on %{link}", link: safe_to_string(docs_link)) %></p>
</div>
<% end %>

<div class="container">
<%= Enum.map(get_flash(@conn), fn {flash_key, flash_message} -> %>
<p class={"notification is-#{flash_key}"}><%= flash_message %></p>
Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ defmodule TeslaMate.MixProject do
{:timex, "~> 3.0"},
{:tortoise, "~> 0.10"},
{:tzdata, "~> 1.1"},
{:websockex, "~> 0.4"}
{:websockex, "~> 0.4"},
{:cloak_ecto, "~> 1.2"}
]
end

Expand Down
Loading

0 comments on commit 0d6e288

Please sign in to comment.