lib/mix/secrex.ex

defmodule Mix.Secrex do
  @moduledoc """
  Utility functions to work with secret files.
  """

  @doc false
  def secret_files() do
    Application.get_env(:secrex, :files, [])
  end

  @doc false
  def encryption_key() do
    key_path = Application.get_env(:secrex, :key_file)

    if key_path do
      key_path |> Path.expand() |> File.read!()
    else
      input_encryption_key("Enter the encryption key:")
    end
    |> String.trim_trailing()
  end

  @doc false
  def get_cipher() do
    Application.get_env(:secrex, :cipher, Secrex.AES)
  end

  @doc ~S"""
  Checks if the local decrypted files are in sync with the encrypted ones.

  This could be useful in deployment process. For instance, to abort deployment if secrets diverge:

      if Mix.Secrex.secret_files_changed?() do
        Mix.raise(
          "Secret files are not in sync. Please run \"mix secrex.decrypt\" to retrieve latest updates."
        )
      end

  """
  @spec secret_files_changed?() :: boolean()
  def secret_files_changed?() do
    key = encryption_key()

    Enum.any?(secret_files(), fn path ->
      enc_path = encrypted_path(path)
      decrypted = decrypt(enc_path, key)
      File.read!(path) != decrypted
    end)
  end

  @doc false
  def encrypted_path(path) do
    path <> ".enc"
  end

  @doc false
  def encrypt(path, key) do
    path |> File.read!() |> get_cipher().encrypt(key)
  end

  @doc false
  def decrypt(path, key) do
    case get_cipher().decrypt(File.read!(path), key) do
      {:ok, decrypted} ->
        decrypted

      {:error, reason} ->
        Mix.raise("Cannot decrypt file, reason: " <> inspect(reason))
    end
  end

  # Encryption key prompt that hides input; taken from Hex.
  defp input_encryption_key(prompt) do
    pid = spawn_link(fn -> input_loop(prompt) end)
    ref = make_ref()
    value = IO.gets(prompt <> " ")

    send(pid, {:done, self(), ref})

    receive do
      {:done, ^pid, ^ref} -> :ok
    end

    value
  end

  defp input_loop(prompt) do
    receive do
      {:done, parent, ref} ->
        send(parent, {:done, self(), ref})
        IO.write(:standard_error, "\e[2K\r")
    after
      1 ->
        IO.write(:standard_error, "\e[2K\r#{prompt} ")
        input_loop(prompt)
    end
  end
end