├── test ├── test_helper.exs ├── secret_vault │ ├── cipher_test.exs │ ├── storage_test.exs │ ├── kdfs │ │ └── pbkdf2_test.exs │ └── cipher │ │ └── erlang_crypto_test.exs └── secret_vault_test.exs ├── .formatter.exs ├── lib ├── secret_vault │ ├── key_derivation.ex │ ├── cipher │ │ ├── plaintext.ex │ │ └── erlang_crypto.ex │ ├── cli.ex │ ├── error_formatter.ex │ ├── kdfs │ │ └── pbkdf2.ex │ ├── cipher.ex │ ├── editor.ex │ ├── storage.ex │ └── config.ex ├── mix │ └── tasks │ │ ├── scr.insert.ex │ │ ├── scr.edit.ex │ │ ├── scr.create.ex │ │ ├── scr.show.ex │ │ └── scr.audit.ex └── secret_vault.ex ├── CHANGELOG.md ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE.md ├── guides └── tutorials │ ├── releases.md │ ├── usage.md │ └── umbrella.md ├── README.md ├── CODE_OF_CONDUCT.md ├── mix.exs └── mix.lock /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | priv = to_string(:code.priv_dir(:secret_vault)) 2 | File.mkdir_p(priv) 3 | ExUnit.start() 4 | File.rm_rf!(priv) 5 | -------------------------------------------------------------------------------- /test/secret_vault/cipher_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.CipherTest do 2 | use ExUnit.Case 3 | doctest SecretVault.Cipher, import: true 4 | end 5 | -------------------------------------------------------------------------------- /test/secret_vault/storage_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.StorageTest do 2 | use ExUnit.Case 3 | doctest SecretVault.Storage, import: true 4 | end 5 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | line_length: 80, 4 | force_do_end_blocks: true, 5 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 6 | ] 7 | -------------------------------------------------------------------------------- /test/secret_vault/kdfs/pbkdf2_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.KDFs.PBKDF2Test do 2 | use ExUnit.Case, async: true 3 | 4 | alias SecretVault.KDFs.PBKDF2 5 | 6 | test "subsequent applications return the same value" do 7 | input = "test" 8 | opts = [key_length: 16, iterations_count: 2] 9 | assert PBKDF2.kdf(input, opts) == PBKDF2.kdf(input, opts) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/secret_vault/key_derivation.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.KeyDerivation do 2 | @moduledoc """ 3 | Defines an interface to derive a key from user-defined password. 4 | """ 5 | 6 | @doc """ 7 | Return binary derived from `user_input`. 8 | """ 9 | @callback kdf(user_input, opts) :: binary() 10 | when user_input: String.t(), 11 | opts: Keyword.t() 12 | end 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ### 1.2.2 4 | 5 | * Fixes lstat bug on non-ext4 filesystems (like btrfs) 6 | 7 | ### 1.2.1 8 | 9 | * Ignores hidden files (those starting with `.`) in secrets directory 10 | 11 | ### 1.2.0 12 | 13 | * Typo in guide fix 14 | 15 | ### 1.1.0 16 | 17 | * Introduces `runtime_secret` family of functions 18 | 19 | ### 1.0.1 20 | 21 | * Fixes the bug with wrong env detected in `fetch_from_current_env` 22 | 23 | # 1.0.0 initial version 24 | -------------------------------------------------------------------------------- /test/secret_vault/cipher/erlang_crypto_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.Cipher.ErlangCryptoTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias SecretVault.Cipher.ErlangCrypto 5 | 6 | test "encryption and decription return original result" do 7 | key = :crypto.strong_rand_bytes(32) 8 | text = "test" 9 | 10 | c = ErlangCrypto.encrypt(key, text, []) 11 | assert {:ok, p} = ErlangCrypto.decrypt(key, c, []) 12 | 13 | assert p == text 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Elixir ${{matrix.elixir}} (Erlang/OTP ${{matrix.otp}}) 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | otp: ['25.0', '24.2', '23.3'] 12 | elixir: ['1.14.1', '1.13.4'] 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: ${{matrix.otp}} 18 | elixir-version: ${{matrix.elixir}} 19 | - run: mix deps.get 20 | - run: mix compile --warnings-as-errors 21 | - run: mix credo --strict 22 | - run: mix format --check-formatted 23 | - run: mix test 24 | -------------------------------------------------------------------------------- /lib/secret_vault/cipher/plaintext.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.Cipher.Plaintext do 2 | @moduledoc """ 3 | Stores passwords in plaintext. 4 | 5 | Implements `SecretVault.Cipher`. 6 | 7 | > #### Warning {: .warning } 8 | > 9 | > This cipher is insecure and it is supposed to be used only in testing 10 | > or in self encrypting filesystems. 11 | """ 12 | 13 | alias SecretVault.Cipher 14 | 15 | @behaviour Cipher 16 | 17 | @impl true 18 | def encrypt(_key, plain_text, _opts) do 19 | Cipher.pack("PLAIN", "PLAIN", [plain_text]) 20 | end 21 | 22 | @impl true 23 | def decrypt(_key, cipher_text, _opts) do 24 | ["PLAIN", splitted_plaintext] = Cipher.unpack!("PLAIN", cipher_text) 25 | {:ok, Enum.join(splitted_plaintext, ";")} 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.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 | secret_vault-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # We don't need any persistant configuration here. 29 | /config/ 30 | 31 | # We don't want to track values created in dev environment. 32 | /priv/secret_vault/ 33 | -------------------------------------------------------------------------------- /lib/secret_vault/cli.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.CLI do 2 | @moduledoc false 3 | # This module is a set of helpers for tasks 4 | 5 | @doc """ 6 | Scans the list of `argv` to find the option in a short or long format 7 | """ 8 | @spec find_option([String.t()], String.t() | nil, String.t()) :: 9 | String.t() | nil 10 | def find_option(argv, short, option) 11 | 12 | def find_option(["--" <> option, value | _rest], _short, option) 13 | when is_binary(option) do 14 | value 15 | end 16 | 17 | def find_option(["-" <> short, value | _rest], short, _option) 18 | when is_binary(short) do 19 | value 20 | end 21 | 22 | def find_option(["--" <> flag | rest], short, option) 23 | when is_binary(option) do 24 | case String.split(flag, "=") do 25 | [^option, value] -> 26 | value 27 | 28 | _ -> 29 | find_option(rest, short, option) 30 | end 31 | end 32 | 33 | def find_option([_ | rest], short, option) do 34 | find_option(rest, short, option) 35 | end 36 | 37 | def find_option([], _, _) do 38 | nil 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, hissssst and yunmikun2 All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 6 | -------------------------------------------------------------------------------- /guides/tutorials/releases.md: -------------------------------------------------------------------------------- 1 | # Mix release integration 2 | 3 | `mix release` allows user to make an aritfact of the application to ease further distribution. 4 | This leads to several limitations which can be solved using SecretVault. 5 | 6 | ## Release configuration 7 | 8 | Specify `secret_vault` or your stub application for secrets as permanent in `mix.exs`. For example: 9 | ```elixir 10 | releases: [ 11 | demo: [ 12 | applications: [ 13 | # For plain apps 14 | secret_vault: :permanent, 15 | 16 | # Or for stub apps (like in umbrella tutorial) 17 | secret_vault_stub: :permanent 18 | ] 19 | ] 20 | ] 21 | ``` 22 | 23 | ## Runtime configuration 24 | 25 | Building Elixir application in releases, generates `sys.config` from compile time configuration, so 26 | default approach with password in `config.exs` will have the password in plain form in `sys.config`. 27 | To avoid this, you must specify secret to be lazyly fetched in runtime. 28 | 29 | So, in your `config/config.exs`: 30 | ```elixir 31 | config :my_app, :secret_vault, 32 | default: [password: {System, :get_env, "VARIABLE_WITH_PASSWORD"}] 33 | ``` 34 | 35 | And in your `config/runtime.exs`: 36 | ```elixir 37 | import SecretVault, only: [runtime_secret: 2] 38 | 39 | config :my_app, :database_password, runtime_secret(:my_app, "database_password") 40 | ``` 41 | -------------------------------------------------------------------------------- /lib/mix/tasks/scr.insert.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Scr.Insert do 2 | @moduledoc """ 3 | Creates a new secret in the specified environment and under the 4 | specified name. 5 | 6 | It uses configuration of the current application to retrieve the 7 | keys and so on. 8 | 9 | ## Usage 10 | 11 | $ mix scr.insert prod database_password "My Super Secret Password" 12 | 13 | ## Config override 14 | 15 | You can override config options by providing command line arguments. 16 | 17 | - `:cipher` - specify a cipher to use; 18 | - `:priv_path` - path to `priv` directory; 19 | - `:prefix` - prefix to use (defaults to `default`); 20 | - `:password` - use a password that's different from the one that's 21 | configured. 22 | """ 23 | 24 | @shortdoc "Inserts a secret" 25 | @requirements ["app.config"] 26 | 27 | use Mix.Task 28 | 29 | alias SecretVault.{CLI, Config, ErrorFormatter} 30 | 31 | @impl true 32 | def run(args) 33 | 34 | def run([env, name, data | rest]) do 35 | otp_app = Mix.Project.config()[:app] 36 | prefix = CLI.find_option(rest, "p", "prefix") || "default" 37 | 38 | config_opts = 39 | Config.available_options() 40 | |> Enum.map(&{&1, CLI.find_option(rest, nil, "#{&1}")}) 41 | |> Enum.reject(fn {_, value} -> is_nil(value) end) 42 | 43 | case Config.fetch_from_env(otp_app, env, prefix, config_opts) do 44 | {:ok, config} -> SecretVault.put(config, name, data) 45 | {:error, error} -> Mix.shell().error(ErrorFormatter.format(error)) 46 | end 47 | end 48 | 49 | def run(_args) do 50 | msg = "Invalid number of arguments. Use `mix help scr.create`." 51 | Mix.shell().error(msg) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/mix/tasks/scr.edit.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Scr.Edit do 2 | @moduledoc """ 3 | Creates a new secret in the specified environment and under the 4 | specified name using your preffered editor. 5 | 6 | It uses configuration of the current application to retrieve the 7 | keys and so on. 8 | 9 | ## Usage 10 | 11 | $ mix scr.edit prod database_url 12 | 13 | ## Config override 14 | 15 | You can override config options by providing command line arguments. 16 | 17 | - `:cipher` - specify a cipher to use; 18 | - `:priv_path` - path to `priv` directory; 19 | - `:prefix` - prefix to use (defaults to `default`); 20 | - `:password` - use a password that's different from the one that's 21 | configured. 22 | """ 23 | 24 | @shortdoc "Create a new secret" 25 | @requirements ["app.config"] 26 | 27 | use Mix.Task 28 | 29 | alias SecretVault.{CLI, Config, Editor, ErrorFormatter} 30 | 31 | @impl true 32 | def run(args) 33 | 34 | def run([environment, name | rest]) do 35 | otp_app = Mix.Project.config()[:app] 36 | prefix = CLI.find_option(rest, "p", "prefix") || "default" 37 | 38 | config_opts = 39 | Config.available_options() 40 | |> Enum.map(&{&1, CLI.find_option(rest, nil, "#{&1}")}) 41 | |> Enum.reject(fn {_, value} -> is_nil(value) end) 42 | 43 | with {:ok, config} <- 44 | Config.fetch_from_env(otp_app, environment, prefix, config_opts), 45 | {:ok, original_data} <- SecretVault.fetch(config, name), 46 | {:ok, updated_data} <- Editor.open_file_on_edit(original_data) do 47 | SecretVault.put(config, name, updated_data) 48 | else 49 | {:error, error} -> Mix.shell().error(ErrorFormatter.format(error)) 50 | end 51 | end 52 | 53 | def run(_args) do 54 | msg = "Invalid number of arguments. Use `mix help scr.edit`." 55 | Mix.shell().error(msg) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SecretVault 🔒 2 | 3 | All-in-one solution for storing your Elixir application secrets inside the repository. 4 | 5 | ## Features 6 | 7 | * **Standalone**. No dependencies on external binaries. 8 | * **Secure**. Uses [aes256gcm](https://en.wikipedia.org/wiki/Galois/Counter_Mode) cipher by default. Detects weak and similar passwords with `mix scr.audit` task. 9 | * **Developer friendly**. You can use `mix scr.*` tasks to create or 10 | edit secrets in your favourit editor. Or you can use simple 11 | coreutils like `mv`, `rm`, `cp`. 12 | * **Easy to use**. Documatation is rich, errors are descriptive and 13 | tutorials take no more than 5 minutes to read. 14 | * **Git friendly**. `SecretVault` stores secrets in separate files, 15 | thus it is really easy to track in Git or any other VCS. 16 | * **Mix friendly**. `SecretVault` enforces separation of secrets for 17 | different environments. 18 | * **Extensible**. You can connect your own ciphers, vaults or key 19 | derivation functions. 20 | * **OTP Compatible**. Uses modern OTP 24 key derivation functions, or 21 | fallbacks to elixir implementation on lower OTP versions. 22 | 23 | ## Usage 24 | 25 | Check out this 5 minutes [usage tutorial](usage.md) for basics and useful links. 26 | 27 | ## Installation 28 | 29 | Just add it to the list of dependencies like 30 | 31 | ```elixir 32 | def deps do 33 | [ 34 | {:secret_vault, "~> 1.0"} 35 | ] 36 | end 37 | ``` 38 | 39 | ## Hacking 40 | 41 | If you want to contribute to the project or just want to test it 42 | localy (not as a dependency), you'll need to create `config/config.exs` 43 | file with following content. 44 | 45 | ```elixir 46 | config :secret_vault, :secret_vault, 47 | default: [password: "Some super secret"] 48 | ``` 49 | 50 | --- 51 | 52 | ### Thanks 53 | 54 | @benonymus -- for battle testing the project and giving the idea for `runtime_secret` macro 55 | -------------------------------------------------------------------------------- /lib/secret_vault/error_formatter.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.ErrorFormatter do 2 | @moduledoc false 3 | # Manages rendering errors in CLI 4 | 5 | @doc """ 6 | Creates a description from error tuple for CLI tasks 7 | """ 8 | @spec format(any()) :: String.t() 9 | def format(message) do 10 | case message do 11 | {:unknown_prefix, prefix, environment} -> 12 | """ 13 | Prefix #{inspect(prefix)} for environment #{inspect(environment)} does not exist. 14 | If you haven't created configuration for prefix, please, refer to usage to tutorial 15 | If you have created configuration for prefix, please, check for spelling errors 16 | """ 17 | 18 | {:secret_already_exists, name} -> 19 | "Secret with name #{name} already exists" 20 | 21 | {:secret_not_found, name, environment} -> 22 | "Secret #{name} not found in environment #{inspect(environment)}" 23 | 24 | {:no_configuration_for_prefix, prefix} -> 25 | """ 26 | No configuration for prefix #{inspect(prefix)} found 27 | If you haven't created configuration for prefix, please, refer to usage to tutorial 28 | If you have created configuration for prefix, please, check for spelling errors 29 | """ 30 | 31 | {:no_configuration_for_app, otp_app} -> 32 | """ 33 | No configuration for otp_app #{otp_app} found 34 | If you haven't created configuration, please, refer to usage to tutorial 35 | If you have created configuration, please, check for spelling errors 36 | """ 37 | 38 | {:non_zero_exit_code, code} -> 39 | "Editor exited with code #{code}" 40 | 41 | {:executable_not_found, editor} -> 42 | "Editor not found: #{editor}" 43 | 44 | :invalid_encryption_key -> 45 | """ 46 | Invalid key. It seems the secret was encrypted with a different encryption key. 47 | This problem can be caused by incorrect password, or enviroment 48 | """ 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/secret_vault/cipher/erlang_crypto.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.Cipher.ErlangCrypto do 2 | @moduledoc """ 3 | `SecretVault.Cipher` implementation which uses `:crypto` module. 4 | 5 | It is enabled by default and it the recommended cipher to use. 6 | As for now it uses `:aes_256_gcm` mode as default and the only available cipher. 7 | """ 8 | 9 | import :crypto, 10 | only: [ 11 | crypto_one_time_aead: 6, 12 | crypto_one_time_aead: 7, 13 | strong_rand_bytes: 1 14 | ] 15 | 16 | alias SecretVault.Cipher 17 | 18 | @behaviour Cipher 19 | 20 | @default_algorithm :aes_256_gcm 21 | @cipher_name "ErlangCrypto" 22 | @default_algorithm_name "AES256GCM" 23 | 24 | @impl true 25 | def encrypt(key, plain_text, _opts) do 26 | iv = strong_rand_bytes(12) 27 | aad = "" 28 | 29 | {encrypted_plain_text, meta} = 30 | crypto_one_time_aead( 31 | @default_algorithm, 32 | key, 33 | iv, 34 | plain_text, 35 | aad, 36 | true 37 | ) 38 | 39 | Cipher.pack(@cipher_name, @default_algorithm_name, [ 40 | iv, 41 | aad, 42 | meta, 43 | encrypted_plain_text 44 | ]) 45 | end 46 | 47 | @impl true 48 | def decrypt(key, cipher_text, _opts) do 49 | case Cipher.unpack!(@cipher_name, cipher_text) do 50 | [@default_algorithm_name, iv, aad, meta, encrypted_plain_text] -> 51 | decrypt_result = 52 | crypto_one_time_aead( 53 | @default_algorithm, 54 | key, 55 | iv, 56 | encrypted_plain_text, 57 | aad, 58 | meta, 59 | false 60 | ) 61 | 62 | case decrypt_result do 63 | :error -> {:error, :invalid_encryption_key} 64 | data when is_binary(data) -> {:ok, data} 65 | end 66 | 67 | list -> 68 | raise Cipher.Error, 69 | "Wrong amount of properties. Expected five props: algo, iv, aad, meta, encrypted_plain_text. Got #{length(list)}" 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/mix/tasks/scr.create.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Scr.Create do 2 | @moduledoc """ 3 | Creates a new secret in the specified environment and under 4 | the specified name using your preffered editor. 5 | 6 | It uses configuration of the current application to retrieve the 7 | keys and other options. 8 | 9 | ## Usage 10 | 11 | $ mix scr.create prod database_url 12 | 13 | ## Config override 14 | 15 | You can override config options by providing command line arguments. 16 | 17 | - `:cipher` - specify a cipher module to use; 18 | - `:priv_path` - path to `priv` directory; 19 | - `:prefix` - prefix to use (defaults to `default`); 20 | - `:password` - use a password that's different from the one that's 21 | configured. 22 | """ 23 | 24 | @shortdoc "Create a new secret" 25 | @requirements ["app.config"] 26 | 27 | use Mix.Task 28 | 29 | alias SecretVault.{CLI, Config, Editor, ErrorFormatter} 30 | 31 | @impl true 32 | def run(args) 33 | 34 | def run([environment, name | rest]) do 35 | otp_app = Mix.Project.config()[:app] 36 | prefix = CLI.find_option(rest, "p", "prefix") || "default" 37 | 38 | config_opts = 39 | Config.available_options() 40 | |> Enum.map(&{&1, CLI.find_option(rest, nil, "#{&1}")}) 41 | |> Enum.reject(fn {_, value} -> is_nil(value) end) 42 | 43 | with {:ok, config} <- 44 | Config.fetch_from_env(otp_app, environment, prefix, config_opts), 45 | :ok <- ensure_secret_doesn_not_exist(config, name), 46 | {:ok, data} <- Editor.open_new_file() do 47 | SecretVault.put(config, name, data) 48 | else 49 | {:error, error} -> Mix.shell().error(ErrorFormatter.format(error)) 50 | end 51 | end 52 | 53 | def run(_args) do 54 | msg = "Invalid number of arguments. Use `mix help scr.create`." 55 | Mix.shell().error(msg) 56 | end 57 | 58 | defp ensure_secret_doesn_not_exist(config, name) do 59 | if SecretVault.exists?(config, name) do 60 | {:error, {:secret_already_exists, name}} 61 | else 62 | :ok 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/secret_vault/kdfs/pbkdf2.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.KDFs.PBKDF2 do 2 | @moduledoc """ 3 | PKCS #2 PBKDF2 (Password-Based Key Derivation Function 2). 4 | 5 | Implements `SecretVault.KeyDerivation`. 6 | """ 7 | 8 | @behaviour SecretVault.KeyDerivation 9 | 10 | @default_key_length 32 11 | @default_iterations_count 5 12 | 13 | @type option :: 14 | {:key_length, pos_integer()} 15 | | {:iterations_count, pos_integer()} 16 | 17 | @doc """ 18 | Call the PBKDF function. 19 | 20 | ## Options 21 | 22 | - `:key_length` - set the key length (default is 32); 23 | - `:iterations_count` - set the count of iterations (default is 5). 24 | """ 25 | @spec kdf(binary(), [option()]) :: binary() 26 | def kdf(user_input, opts) do 27 | key_length = Keyword.get(opts, :key_length, @default_key_length) 28 | 29 | iterations_count = 30 | Keyword.get(opts, :iterations_count, @default_iterations_count) 31 | 32 | salt = "" 33 | 34 | do_kdf(user_input, salt, iterations_count, key_length) 35 | end 36 | 37 | cond do 38 | Code.ensure_loaded?(:crypto) && function_exported?(:crypto, :pbkdf2_hmac, 5) -> 39 | # Note: This function is only available since OTP 24.2. 40 | defp do_kdf(user_input, salt, iterations_count, key_length) do 41 | :crypto.pbkdf2_hmac( 42 | :sha512, 43 | user_input, 44 | salt, 45 | iterations_count, 46 | key_length 47 | ) 48 | end 49 | 50 | Code.ensure_loaded?(Pbkdf2KeyDerivation) && 51 | function_exported?(Pbkdf2KeyDerivation, :pbkdf2!, 5) -> 52 | defp do_kdf(user_input, salt, iterations_count, key_length) do 53 | Pbkdf2KeyDerivation.pbkdf2!( 54 | user_input, 55 | salt, 56 | :sha512, 57 | iterations_count, 58 | key_length 59 | ) 60 | end 61 | 62 | true -> 63 | raise CompileError, 64 | description: 65 | "It seems your OTP version is under 24.2 and you didn't " <> 66 | "install :pbkdf2_key_derivation library" 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/secret_vault_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SecretVaultTest do 2 | use ExUnit.Case, async: true 3 | doctest SecretVault, import: true 4 | 5 | alias SecretVault.Config 6 | 7 | test "you can reed a secret after you've written it" do 8 | password = "password" 9 | config = Config.new(:secret_vault, password: password) 10 | name = "secret_name_#{Enum.random(0..1000)}" 11 | data = "test data" 12 | 13 | on_exit(fn -> File.rm_rf!(SecretVault.resolve_environment_path(config)) end) 14 | 15 | assert :ok = SecretVault.put(config, name, data) 16 | 17 | assert {:ok, read} = SecretVault.fetch(config, name) 18 | 19 | assert read == data 20 | end 21 | 22 | describe "Testing in mix" do 23 | setup do 24 | project = 25 | MixTester.setup( 26 | name: "my_project", 27 | application_env: %{ 28 | "config" => %{ 29 | {:my_project, :secret_vault} => [default: [password: "password"]] 30 | } 31 | }, 32 | project: [ 33 | deps: [secret_vault: [path: File.cwd!()]] 34 | ] 35 | ) 36 | 37 | on_exit(fn -> MixTester.cleanup(project) end) 38 | 39 | MixTester.write_ast( 40 | project, 41 | "test/my_project_test.exs", 42 | quote do 43 | defmodule MyProjectTest do 44 | use ExUnit.Case 45 | 46 | test "secret works" do 47 | {:ok, config} = 48 | SecretVault.Config.fetch_from_current_env(:my_project) 49 | 50 | SecretVault.Storage.to_application_env(config, :my_project) 51 | 52 | assert Application.get_env(:my_project, :secret_storage) == %{ 53 | "x" => "secret" 54 | } 55 | end 56 | end 57 | end 58 | ) 59 | 60 | MixTester.sh(project, "mkdir priv") 61 | 62 | {:ok, project: project} 63 | end 64 | 65 | test "Hidden files ignored", %{project: project} do 66 | MixTester.mix_cmd(project, "scr.insert", ["test", "x", "secret"]) 67 | assert {_, 0} = MixTester.mix_cmd(project, "test") 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/mix/tasks/scr.show.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Scr.Show do 2 | @moduledoc """ 3 | Shows an existing secret in the specified environment and under 4 | the specified name. 5 | 6 | If the name of a secret is not provided, it lists all the existing 7 | secrets. 8 | 9 | It uses configuration of the current application to retrieve the 10 | keys and so on. 11 | 12 | ## Usage 13 | 14 | To show the secret use 15 | 16 | $ mix scr.show prod database_url 17 | 18 | If you want to list all the available secrets for the environment, 19 | you can run 20 | 21 | $ mix scr.show prod 22 | 23 | ## Config override 24 | 25 | You can override config options by providing command line arguments. 26 | 27 | - `:cipher` - specify a cipher to use; 28 | - `:priv_path` - path to `priv` directory; 29 | - `:prefix` - prefix to use (defaults to `default`); 30 | - `:password` - use a password that's different from the one that's 31 | configured. 32 | """ 33 | 34 | @shortdoc "Show an existing secret or list all the ones" 35 | @requirements ["app.config"] 36 | 37 | use Mix.Task 38 | 39 | alias SecretVault.{CLI, Config, ErrorFormatter} 40 | 41 | @impl true 42 | def run(args) 43 | 44 | def run([env, name | rest]) do 45 | otp_app = Mix.Project.config()[:app] 46 | prefix = CLI.find_option(rest, "p", "prefix") || "default" 47 | 48 | config_opts = 49 | Config.available_options() 50 | |> Enum.map(&{&1, CLI.find_option(rest, nil, "#{&1}")}) 51 | |> Enum.reject(fn {_, value} -> is_nil(value) end) 52 | 53 | with {:ok, config} <- 54 | Config.fetch_from_env(otp_app, env, prefix, config_opts), 55 | {:ok, data} <- SecretVault.fetch(config, name) do 56 | Mix.shell().info(data) 57 | else 58 | {:error, error} -> Mix.shell().error(ErrorFormatter.format(error)) 59 | end 60 | end 61 | 62 | def run([environment | rest]) do 63 | otp_app = Mix.Project.config()[:app] 64 | prefix = CLI.find_option(rest, "p", "prefix") || "default" 65 | 66 | with {:ok, config} <- Config.fetch_from_env(otp_app, environment, prefix), 67 | {:ok, names} <- SecretVault.list(config) do 68 | message = Enum.join(names, "\n") 69 | Mix.shell().info(message) 70 | else 71 | {:error, error} -> Mix.shell().error(ErrorFormatter.format(error)) 72 | end 73 | end 74 | 75 | def run(_args) do 76 | msg = "Invalid number of arguments. Use `mix help scr.show`." 77 | Mix.shell().error(msg) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Why have a Code of Conduct? 4 | 5 | As contributors and maintainers of this project, we are committed to providing a friendly, safe and welcoming environment for all, regardless of age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. 6 | 7 | The goal of the Code of Conduct is to specify a baseline standard of behavior so that people with different social values and communication styles can talk about SecretVault effectively, productively, and respectfully, even in face of disagreements. The Code of Conduct also provides a mechanism for resolving conflicts in the community when they arise. 8 | 9 | ## Our Values 10 | 11 | These are the values SecretVault developers should aspire to: 12 | 13 | * Be friendly and welcoming 14 | * Be kind 15 | * Remember that people have varying communication styles and that not everyone is using their native language. (Meaning and tone can be lost in translation.) 16 | * Interpret the arguments of others in good faith, do not seek to disagree. 17 | * When we do disagree, try to understand why. 18 | * Be thoughtful 19 | * Productive communication requires effort. Think about how your words will be interpreted. 20 | * Remember that sometimes it is best to refrain entirely from commenting. 21 | * Be respectful 22 | * In particular, respect differences of opinion. It is important that we resolve disagreements and differing views constructively. 23 | * Be constructive 24 | * Avoid derailing: stay on topic; if you want to talk about something else, start a new conversation. 25 | * Avoid unconstructive criticism: don't merely decry the current state of affairs; offer — or at least solicit — suggestions as to how things may be improved. 26 | * Avoid harsh words and stern tone: we are all aligned towards the well-being of the community and the progress of the ecosystem. Harsh words exclude, demotivate, and lead to unnecessary conflict. 27 | * Avoid snarking (pithy, unproductive, sniping comments). 28 | * Avoid microaggressions (brief and commonplace verbal, behavioral and environmental indignities that communicate hostile, derogatory or negative slights and insults towards a project, person or group). 29 | * Be responsible 30 | * What you say and do matters. Take responsibility for your words and actions, including their consequences, whether intended or otherwise. 31 | 32 | The following actions are explicitly forbidden: 33 | 34 | * Insulting, demeaning, hateful, or threatening remarks. 35 | * Discrimination based on age, disability, gender, nationality, race, religion, sexuality, or similar personal characteristic. 36 | * Bullying or systematic harassment. 37 | * Unwelcome sexual advances. 38 | * Incitement to any of these. 39 | 40 | 41 | ## Acknowledgements 42 | 43 | This document was based on the Code of Conduct from the Elixir project. 44 | 45 | https://github.com/elixir-lang/ 46 | -------------------------------------------------------------------------------- /lib/secret_vault/cipher.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.Cipher do 2 | @moduledoc """ 3 | Provides an interface to implement encryption methods. 4 | 5 | See `SecretVault.Cipher.ErlangCrypto` implementation for more 6 | details. 7 | """ 8 | 9 | @typedoc """ 10 | A key for symetric encryption algorithm as a binary. 11 | """ 12 | @type key :: binary() 13 | 14 | @typedoc """ 15 | Binary blob that contains encrypted data. 16 | """ 17 | @type cypher_text :: binary() 18 | 19 | @typedoc """ 20 | Binary blob that contains decrypted data. 21 | """ 22 | @type plain_text :: binary() 23 | 24 | @doc """ 25 | Encrypt the `plain_text` with the `key`. 26 | """ 27 | @callback encrypt(key, plain_text, opts) :: cypher_text 28 | when opts: Keyword.t() 29 | 30 | @doc """ 31 | Decrypt the `cypher_text` with the `key` used to get it. 32 | 33 | If key is invalid, `:invalid_key` error is returned. If 34 | inappropriate text is passed as `cypher_text`, `Cipher.Error` is 35 | raised. 36 | """ 37 | @callback decrypt(key, cypher_text, opts) :: 38 | {:ok, plain_text} | {:error, :invalid_encryption_key} 39 | when opts: Keyword.t() 40 | 41 | defmodule Error do 42 | @moduledoc """ 43 | This exception gets raised when some error occurs during decoding of an encrypted secret. 44 | """ 45 | defexception [:message] 46 | end 47 | 48 | @doc """ 49 | Serialize encryption metadata end a ciphertext into a single binary. 50 | 51 | This is a helper function to prepare the data to be written on the 52 | disk. 53 | 54 | ## Example 55 | 56 | iex> cipher = "MyNewCipher" 57 | ...> algorithm = "default" 58 | ...> ciphertext = "testtest" 59 | ...> pack(cipher, algorithm, [ciphertext]) 60 | "MyNewCipher;default;7465737474657374" 61 | """ 62 | @spec pack(cipher, algorithm, [property]) :: binary 63 | when cipher: String.t(), algorithm: String.t(), property: binary 64 | def pack(cipher, algorithm, properties) 65 | when is_binary(cipher) and is_binary(algorithm) and is_list(properties) do 66 | encoded_properties = Enum.map(properties, &Base.encode16/1) 67 | Enum.join([cipher, algorithm | encoded_properties], ";") 68 | end 69 | 70 | @doc """ 71 | Deserialize `pack/3`'ed data. 72 | 73 | ## Example 74 | 75 | iex> cipher = "MyNewCipher" 76 | iex> serialized = "MyNewCipher;default;7465737474657374" 77 | iex> unpack!(cipher, serialized) 78 | ["default", "testtest"] 79 | """ 80 | @spec unpack!(cipher, binary) :: [property] 81 | when cipher: String.t(), property: binary 82 | def unpack!(cipher, binary) 83 | when is_binary(cipher) and is_binary(binary) do 84 | case String.split(binary, ";") do 85 | [^cipher, algorithm | properties] -> 86 | properties = Enum.map(properties, &Base.decode16!/1) 87 | [algorithm | properties] 88 | 89 | [other_cipher | _] -> 90 | raise Error, "Wrong cipher!. Expected #{cipher}, got: #{other_cipher}" 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/secret_vault/editor.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.Editor do 2 | @moduledoc false 3 | # This module incapsulates working with starting a text editor. 4 | 5 | @spec open_new_file() :: {:ok, data} | {:error, error} 6 | when data: String.t(), 7 | error: 8 | {:non_zero_exit_code, code :: integer, message :: String.t()} 9 | | {:executable_not_found, editor :: String.t()} 10 | def open_new_file do 11 | open_file_on_edit("") 12 | end 13 | 14 | @spec open_file_on_edit(data) :: {:ok, data} | {:error, error} 15 | when data: String.t(), 16 | error: 17 | {:non_zero_exit_code, code :: integer, message :: String.t()} 18 | | {:executable_not_found, editor :: String.t()} 19 | def open_file_on_edit(data) when is_binary(data) do 20 | editor = find_editor() 21 | tmp_path = accuire_tmp_file!() 22 | File.write!(tmp_path, data) 23 | 24 | case do_open_file_on_edit(editor, tmp_path) do 25 | :ok -> 26 | data = File.read!(tmp_path) 27 | File.rm!(tmp_path) 28 | {:ok, data} 29 | 30 | {:error, _} = error -> 31 | # Trying to delete tmp_path to make sure it's not left behind. 32 | # It's possible that the file was not even created, so we 33 | # cannot fail if the file does not exist. 34 | File.rm(tmp_path) 35 | error 36 | end 37 | end 38 | 39 | defp do_open_file_on_edit(editor, tmp_path) do 40 | with exe when not is_nil(exe) <- System.find_executable(editor), 41 | running_editor <- run_editor(exe, tmp_path) do 42 | await_editor(running_editor) 43 | else 44 | nil -> {:error, {:executable_not_found, editor}} 45 | {msg, code} -> {:error, {:non_zero_exit_code, code, msg}} 46 | end 47 | catch 48 | :error, error when error in ~w[enoent eacces]a -> 49 | {:error, {:executable_not_found, editor}} 50 | end 51 | 52 | # Starts editor instance in a separate port 53 | defp run_editor(editor_path, tmp_path) do 54 | Port.open({:spawn_executable, editor_path}, [ 55 | :nouse_stdio, 56 | :exit_status, 57 | :eof, 58 | args: [tmp_path] 59 | ]) 60 | end 61 | 62 | # Awaits exit code from the editor for 10 minutes 63 | defp await_editor(port, timeout \\ 10 * 60_000) do 64 | receive do 65 | {^port, {:exit_status, 0}} -> :ok 66 | {^port, {:exit_status, status}} -> {:error, {:non_zero_exit_code, status}} 67 | after 68 | timeout -> 69 | Port.close(port) 70 | {:error, :timeout} 71 | end 72 | end 73 | 74 | # Searches for the editor command in EDITOR, VISUAL and xdg-open command 75 | defp find_editor do 76 | editor_candidates = [ 77 | System.get_env("VISUAL"), 78 | System.get_env("EDITOR"), 79 | "xdg-open" 80 | ] 81 | 82 | Enum.find(editor_candidates, &(&1 not in [nil, ""])) 83 | end 84 | 85 | # Creates a protected file in tmp upon which the editor will be called 86 | defp accuire_tmp_file! do 87 | tmp_file_name = Base.encode16(:crypto.strong_rand_bytes(16)) <> ".txt" 88 | tmp_path = Path.join(System.tmp_dir!(), tmp_file_name) 89 | File.mkdir_p!(Path.dirname(tmp_path)) 90 | File.touch!(tmp_path) 91 | # Read/write for owner only 92 | File.chmod!(tmp_path, 0o600) 93 | tmp_path 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.MixProject do 2 | use Mix.Project 3 | 4 | @version "1.2.3" 5 | @source "https://github.com/SecretVault-elixir/secret_vault" 6 | 7 | def project do 8 | [ 9 | app: :secret_vault, 10 | name: "SecretVault", 11 | version: @version, 12 | elixir: "~> 1.11", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | description: description(), 16 | package: package(), 17 | source_url: @source, 18 | homepage_url: @source, 19 | docs: docs(), 20 | dialyzer: dialyzer() 21 | ] 22 | end 23 | 24 | defp description do 25 | "All-included solution for managing secrets in mix projects" 26 | end 27 | 28 | defp package do 29 | [ 30 | description: description(), 31 | licenses: [~S|BSD 2-Clause "Simplified" License|], 32 | files: [ 33 | "lib", 34 | "mix.exs", 35 | "README.md", 36 | ".formatter.exs" 37 | ], 38 | maintainers: [ 39 | "hissssst", 40 | "yunmikun2" 41 | ], 42 | links: %{ 43 | GitHub: @source, 44 | Changelog: "#{@source}/blob/main/CHANGELOG.md" 45 | } 46 | ] 47 | end 48 | 49 | defp docs do 50 | [ 51 | source_ref: "v#{@version}", 52 | main: "readme", 53 | 54 | # TODO 55 | # extra_section: "GUIDES", 56 | extra_section: "GUIDES", 57 | extras: ["README.md" | Path.wildcard("guides/*/*")] ++ ["CHANGELOG.md"], 58 | groups_for_modules: groups_for_modules(), 59 | groups_for_extras: groups_for_extras() 60 | ] 61 | end 62 | 63 | def application do 64 | [ 65 | extra_applications: [:logger, :crypto] 66 | ] 67 | end 68 | 69 | defp deps do 70 | [ 71 | # Testing mix tasks 72 | {:mix_tester, "~> 1.0", only: :test}, 73 | 74 | # Type checking 75 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 76 | {:gradient, github: "esl/gradient", only: :dev, runtime: false}, 77 | 78 | # Linting 79 | {:mix_unused, "~> 0.4", only: :dev, runtime: false}, 80 | {:credo, "~> 1.6", only: :dev, runtime: false}, 81 | 82 | # Documentation 83 | {:ex_doc, "~> 0.28", only: :dev, runtime: false}, 84 | 85 | # PBKDF 86 | {:pbkdf2_key_derivation, "~> 2.0", optional: true} 87 | ] 88 | end 89 | 90 | defp dialyzer do 91 | [ 92 | plt_add_apps: [:mix] 93 | ] 94 | end 95 | 96 | defp groups_for_extras do 97 | [ 98 | Tutorials: ~r/guides\/tutorials\/.*/ 99 | ] 100 | end 101 | 102 | defp groups_for_modules do 103 | [ 104 | Runtime: [ 105 | SecretVault, 106 | SecretVault.Config, 107 | SecretVault.Storage 108 | ], 109 | Development: [ 110 | Mix.Tasks.Scr.Create, 111 | Mix.Tasks.Scr.Edit, 112 | Mix.Tasks.Scr.Show, 113 | Mix.Tasks.Scr.Audit, 114 | Mix.Tasks.Scr.Insert 115 | ], 116 | Ciphers: [ 117 | SecretVault.Cipher, 118 | SecretVault.Cipher.ErlangCrypto, 119 | SecretVault.Cipher.Plaintext 120 | ], 121 | "Key Derivation": [ 122 | SecretVault.KeyDerivation, 123 | SecretVault.KDFs.PBKDF2 124 | ] 125 | ] 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/secret_vault/storage.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.Storage do 2 | @moduledoc """ 3 | Module with helpers to store secrets in various storages. 4 | 5 | ## Transform function 6 | 7 | All functions in this module accept an optional argument: 8 | [`transform`](`t:transform_function/0`) which is a function to 9 | transform secret values. This may be helpful if your storage doesn't 10 | support string keys (or may be you don't want to deal with them) or 11 | if you just want to transform the data somehow. 12 | """ 13 | 14 | alias SecretVault.Config 15 | 16 | @typedoc """ 17 | A function that transforms values fetched from the secret vault. 18 | """ 19 | @type transform_function :: 20 | (String.t(), String.t() -> {a :: term(), b :: term()}) 21 | 22 | @doc """ 23 | Stores secrets in a `:persistent_term`. 24 | 25 | Stored secrets can be accessed with `:persistent_term.get(name)` 26 | 27 | ## Example 28 | 29 | iex> config = SecretVault.Config.test_config() 30 | iex> SecretVault.put(config, "name", "value") 31 | iex> to_persistent_term(config) 32 | iex> :persistent_term.get("name") 33 | "value" 34 | """ 35 | def to_persistent_term(%Config{} = config, transform \\ &no_transform/2) 36 | when is_function(transform, 2) do 37 | at_key_value(config, transform, fn {key, value} -> 38 | :persistent_term.put(key, value) 39 | end) 40 | end 41 | 42 | @doc """ 43 | Stores secrets in a `:ets` table using `:ets.insert`. 44 | 45 | Stored secrets can be accessed with `:ets.lookup(table, name)` or 46 | other `:ets` functions. 47 | 48 | ## Example 49 | 50 | iex> config = SecretVault.Config.test_config() 51 | iex> SecretVault.put(config, "name", "value") 52 | iex> table = :ets.new(:example_table, [:set, :protected]) 53 | iex> to_ets(config, table) 54 | iex> :ets.lookup(table, "name") 55 | [{"name", "value"}] 56 | """ 57 | def to_ets(config, table, transform \\ &no_transform/2) 58 | when is_function(transform, 2) do 59 | at_key_value(config, transform, &:ets.insert(table, &1)) 60 | end 61 | 62 | @doc """ 63 | Stores secrets in a `Application` env using `Application.put_env/4`. 64 | 65 | Stored secrets can be accessed with 66 | `Application.fetch_env!(application_name, :secret_storage)[name]`. 67 | 68 | ## Example 69 | 70 | iex> config = SecretVault.Config.test_config() 71 | iex> SecretVault.put(config, "name", "value") 72 | iex> to_application_env(config, :secret_vault) 73 | iex> Application.fetch_env!(:secret_vault, :secret_storage)["name"] 74 | "value" 75 | """ 76 | def to_application_env( 77 | config, 78 | application_name, 79 | env_key \\ :secret_storage, 80 | transform \\ &no_transform/2 81 | ) 82 | when is_function(transform, 2) do 83 | with {:ok, map} <- SecretVault.fetch_all(config) do 84 | map = Map.new(map, fn {key, value} -> transform.(key, value) end) 85 | Application.put_env(application_name, env_key, map) 86 | end 87 | end 88 | 89 | @doc """ 90 | Stores secrets in a `Application` env using `Process.put/2`. 91 | 92 | Stored secrets can be accessed with `Process.get(name)` 93 | 94 | ## Example 95 | 96 | iex> config = SecretVault.Config.test_config() 97 | iex> SecretVault.put(config, "name", "value") 98 | iex> to_proccess_dictionary(config) 99 | iex> Process.get("name") 100 | "value" 101 | """ 102 | def to_proccess_dictionary(config, transform \\ &no_transform/2) 103 | when is_function(transform, 2) do 104 | at_key_value(config, transform, fn {key, value} -> 105 | Process.put(key, value) 106 | end) 107 | end 108 | 109 | defp no_transform(key, value) do 110 | {key, value} 111 | end 112 | 113 | defp at_key_value(config, transform, closure) do 114 | with {:ok, map} <- SecretVault.fetch_all(config) do 115 | Enum.each(map, fn {key, value} -> 116 | entry = transform.(key, value) 117 | closure.(entry) 118 | end) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 6 | "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, 7 | "ex_doc": {:hex, :ex_doc, "0.39.1", "e19d356a1ba1e8f8cfc79ce1c3f83884b6abfcb79329d435d4bbb3e97ccc286e", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "8abf0ed3e3ca87c0847dfc4168ceab5bedfe881692f1b7c45f4a11b232806865"}, 8 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 9 | "gradient": {:git, "https://github.com/esl/gradient.git", "33e13fbe1ff60a49abdc638d76b77effddf3cc45", []}, 10 | "gradient_macros": {:git, "https://github.com/esl/gradient_macros.git", "3bce2146bf0cdf380f773c40e2b7bd6558ab6de8", [ref: "3bce214"]}, 11 | "gradualizer": {:git, "https://github.com/josefs/Gradualizer.git", "3021d29d82741399d131e3be38d2a8db79d146d4", [tag: "0.3.0"]}, 12 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 13 | "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, 14 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 15 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 16 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 17 | "mix_tester": {:hex, :mix_tester, "1.1.1", "3cb546cdc739163a7b5ebfa7908006f2db540943cf0c40872a753dae6a9e4dfa", [:mix], [], "hexpm", "4495a503a47220d6a6c27d957a9296b47e2411ad11ac8b503a25bdd3cee0a5d8"}, 18 | "mix_unused": {:hex, :mix_unused, "0.4.1", "9f8d759a300a79d2077d6baf617f3a5af6935d50b0f113c09295b265afc3e411", [:mix], [{:libgraph, ">= 0.0.0", [hex: :libgraph, repo: "hexpm", optional: false]}], "hexpm", "fa21f688a88e0710e3d96ac1c8e5a6181aea8a75c8a4214f0edcfeb069b831a3"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 20 | "pbkdf2_key_derivation": {:hex, :pbkdf2_key_derivation, "2.0.0", "23e47e8f30ac37176b2f3ac8224032ab36c7e5124fe9d2d0417690df1154c77a", [:mix], [], "hexpm", "8b2597ec9c0e2d537f0e02237641b441aa95b3bca74097d59fe0de7c1c823925"}, 21 | "sourceror": {:hex, :sourceror, "0.12.3", "a2ad3a1a4554b486d8a113ae7adad5646f938cad99bf8bfcef26dc0c88e8fade", [:mix], [], "hexpm", "4d4e78010ca046524e8194ffc4683422f34a96f6b82901abbb45acc79ace0316"}, 22 | } 23 | -------------------------------------------------------------------------------- /lib/mix/tasks/scr.audit.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Scr.Audit do 2 | @moduledoc """ 3 | Performs audit of passwords, detect duplicates and weak passwords. 4 | Exits with exit code `1` if at least one check fails. 5 | 6 | ## Usage 7 | 8 | $ mix scr.audit 9 | 10 | ## Check options 11 | 12 | - `--no-similarity` - to disable password similarity check 13 | - `--digits` - to enforce passwords having at least one digit 14 | - `--uppercase` - to enforce uppercase letters in password 15 | - `--mix-length=N` - to enforce minimul length requirement 16 | - `--no-plaintext` - to disable checking for unencrypted passwords 17 | 18 | ## Config override 19 | 20 | You can override config options by providing command line arguments. 21 | 22 | - `:cipher` - specify a cipher module to use; 23 | - `:priv_path` - path to `priv` directory; 24 | - `:prefix` - prefix to use (defaults to `default`); 25 | - `:password` - use a password that's different from the one that's 26 | configured. 27 | """ 28 | 29 | @shortdoc "Performs audit of passwords" 30 | @requirements ["app.config"] 31 | 32 | use Mix.Task 33 | 34 | require Config 35 | alias Elixir.Config.Reader 36 | alias SecretVault.{CLI, Config} 37 | 38 | @spec run([String.t()]) :: no_return() 39 | def run(args) do 40 | otp_app = Mix.Project.config()[:app] 41 | 42 | config_file = 43 | Enum.find(Mix.Project.config_files(), fn file -> 44 | String.ends_with?(file, "/config.exs") 45 | end) || 46 | raise "No config.exs found" 47 | 48 | envs = ~w[prod test dev]a 49 | 50 | config_opts = 51 | Config.available_options() 52 | |> Enum.map(&{&1, CLI.find_option(args, nil, "#{&1}")}) 53 | |> Enum.reject(fn {_, value} -> is_nil(value) end) 54 | 55 | Enum.flat_map(envs, fn env -> 56 | # Mix.env(env) 57 | Reader.read!(config_file, env: env) 58 | 59 | otp_app 60 | |> Application.get_env(:secret_vault, []) 61 | |> Enum.flat_map(fn {prefix, _} -> 62 | prefix = to_string(prefix) 63 | 64 | {:ok, config} = 65 | Config.fetch_from_env(otp_app, "#{env}", prefix, config_opts) 66 | 67 | do_fetch(config) 68 | end) 69 | end) 70 | |> check(args) 71 | 72 | System.halt(Process.get(:status, 0)) 73 | end 74 | 75 | defp do_fetch(config) do 76 | case SecretVault.fetch_all(config) do 77 | {:ok, secrets} -> 78 | for {name, value} <- secrets do 79 | {config, name, value} 80 | end 81 | 82 | {:error, {:unknown_prefix, _, _}} -> 83 | [] 84 | end 85 | end 86 | 87 | defp check(secrets, args) do 88 | unless "--no-plaintext" in args do 89 | plaintext_check(secrets) 90 | end 91 | 92 | unless "--no-similarity" in args do 93 | similarity_check(secrets) 94 | end 95 | 96 | if "--digits" in args do 97 | digits_check(secrets) 98 | end 99 | 100 | if "--uppercase" in args do 101 | uppercase_check(secrets) 102 | end 103 | 104 | len = CLI.find_option(args, "l", "min-length") || "16" 105 | length_check(secrets, String.to_integer(len)) 106 | end 107 | 108 | defp plaintext_check(secrets) do 109 | Enum.each(secrets, fn {config, _, _} = secret -> 110 | if config.cipher == SecretVault.Cipher.Plaintext do 111 | Mix.shell().error("#{pathify(secret)} contains plaintext password") 112 | Process.put(:status, 1) 113 | end 114 | end) 115 | end 116 | 117 | defp uppercase_check(secrets) do 118 | Enum.each(secrets, fn {_, _, value} = secret -> 119 | unless value =~ ~r/[A-Z]/ do 120 | Mix.shell().error( 121 | "#{pathify(secret)} does not contain uppercase symbols" 122 | ) 123 | 124 | Process.put(:status, 1) 125 | end 126 | end) 127 | end 128 | 129 | defp length_check(secrets, len) do 130 | Enum.each(secrets, fn {_, _, value} = secret -> 131 | if byte_size(value) < len do 132 | Mix.shell().error("#{pathify(secret)} is too short") 133 | Process.put(:status, 1) 134 | end 135 | end) 136 | end 137 | 138 | defp digits_check(secrets) do 139 | Enum.each(secrets, fn {_, _, value} = secret -> 140 | unless value =~ ~r/\d/ do 141 | Mix.shell().error("#{pathify(secret)} does not contain digits") 142 | Process.put(:status, 1) 143 | end 144 | end) 145 | end 146 | 147 | defp similarity_check([{_, _, left_value} = left | rest]) do 148 | Enum.each(rest, fn {_, _, right_value} = right -> 149 | if String.jaro_distance(left_value, right_value) > 0.5 do 150 | Mix.shell().error( 151 | "#{pathify(right)} and #{pathify(left)} secrets are too similar" 152 | ) 153 | 154 | Process.put(:status, 1) 155 | end 156 | end) 157 | 158 | similarity_check(rest) 159 | end 160 | 161 | defp similarity_check([]) do 162 | [] 163 | end 164 | 165 | defp pathify({config, name, _}) do 166 | path = SecretVault.resolve_secret_path(config, name) 167 | 168 | case File.cwd() do 169 | {:ok, cwd} -> Path.relative_to(path, cwd) 170 | _ -> path 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /guides/tutorials/usage.md: -------------------------------------------------------------------------------- 1 | # Usage tutorial 2 | 3 | This is a 5 minutes `SecretVault` tutorial that shows how to install 4 | and configure `SecretVault`, and then create, delete, edit, and access 5 | secrets in runtime. 6 | 7 | ## Install 8 | 9 | Just add it into your dependencies like 10 | ```elixir 11 | defp deps do 12 | [ 13 | {:secret_vault, "~> 1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | ## Create priv dir 19 | 20 | Just create a `priv` directory in the root of your project 21 | 22 | ## Configure 23 | 24 | Configuration is straightforward. Minimal configuration would look 25 | like this: 26 | 27 | ```elixir 28 | import Config 29 | 30 | config :my_app, :secret_vault, 31 | default: [password: System.fetch_env!("SECRET_VAULT_PASSWORD")] 32 | 33 | # Here `default` is a name of a default prefix. Prefixes work like namespaces for secrets. 34 | ``` 35 | 36 | 37 | You can provide options other than 38 | `password`. To see a full list of those check out the 39 | `SecretVault.Config.new/2` documentation. 40 | 41 | > ### Note {: .info } 42 | > 43 | > Each `MIX_ENV` will have it's own separate vault, so don't use 44 | > prefixes to separate envs. 45 | 46 | ## Create secrets 47 | 48 | To create a secret during development, you can use one of many secret 49 | creating tasks available. But do not forget to provide the enviroment 50 | variable with the password which we specified above. 51 | 52 | For example, 53 | 54 | ```sh 55 | $ export SECRET_VAULT_PASSWORD="password" # Don't forget to change the password value 56 | $ mix scr.insert dev database_password "My Super Secret Password" 57 | ``` 58 | 59 | Or, to be able to write a password in your favourite editor, use 60 | 61 | ```sh 62 | $ mix scr.create dev database_password 63 | ``` 64 | 65 | Here `dev` defines the `MIX_ENV` for which you'd like to create a 66 | secret. And `database_password` is a name of the secret. These exact 67 | commands will create a secret in 68 | `priv/dev/default/database_password.vault_secret`. Each secret is 69 | written onto it's own file in vault directory in `priv`. This plays 70 | nice with version control systems and simplifies the user interface. 71 | 72 | > ### Note {: .info } 73 | > 74 | > Each secret is written into it's own file with `.vault_secret` 75 | > extension. Therefore, secrets' names must be suitable file names. 76 | 77 | ## Manipulate secrets 78 | 79 | To edit already created secrets, one can use `mix scr.insert` and `mix 80 | scr.edit` commands. To delete, rename or copy secrets, one 81 | can use regular coreutils like `mv`, `cp`, `rm`, since each secret is 82 | placed under the corresponding directory in `priv`. Usually the path 83 | is `priv/$MIX_ENV/$PREFIX/${SECRET_NAME}.vault_secret`. 84 | 85 | For example, to delete secret which was created in previous step, one 86 | could write: 87 | 88 | ```sh 89 | $ rm priv/dev/default/database_password.vault_secret 90 | ``` 91 | 92 | Or to rename a secret: 93 | ```sh 94 | $ cd priv/dev/default/ 95 | $ mv database_password.vault_secret db_password.vault_secret 96 | ``` 97 | 98 | ## Access secrets 99 | 100 | To access and use secrets from `Elixir` application, one first need to 101 | retrieve the vault configuration. Then, it is recommended to place the 102 | secrets into some runtime storage (like `persistent_term`, for 103 | example). 104 | 105 | So, regular workflow would look like 106 | 107 | ```elixir 108 | defmodule MyApp.Application do 109 | use Application 110 | 111 | def start(_type, _args) do 112 | ... 113 | {:ok, config} = SecretVault.Config.fetch_from_current_env(:my_app) 114 | SecretVault.Storage.to_persistent_term(config) 115 | end 116 | end 117 | ``` 118 | 119 | This will create config from configuration of your application and put 120 | all decrypted passwords in the `persistent_term`. See 121 | `SecretVault.Config` and `SecretVault.Storage` for more options. 122 | 123 | If you want to have your options in application env you can specify 124 | this in `config.exs` 125 | 126 | ```elixir 127 | # in config/config.exs 128 | import Config 129 | 130 | config :my_app, :secret_vault, 131 | default: [password: System.fetch_env!("SECRET_VAULT_PASSWORD")] 132 | 133 | # in application.ex start function 134 | {:ok, config} = SecretVault.Config.fetch_from_current_env(:my_app) 135 | SecretVault.Storage.to_application_env(config) 136 | ``` 137 | 138 | ## Runtime configuration 139 | 140 | It is a common practice to configure application in configuration scripts like `config/config.exs`, `config/dev.exs` and `config/runtime.exs`. And there are two things 141 | a developer must keep in mind while working with them 142 | 143 | First of all, you **must not** use compile time configuration scritps (basically everything except `runtime.exs`) for setting values from secrets, since these values will appear in `app.src` file in your `_build` or release ebin directory. Usually, this is not something you can tolerate 144 | 145 | Second, `runtime.exs` config will be called during initialization of your project and the enviroment of the project will be inherited from the enviroment which was during project compilation. This means, that for release created with `MIX_ENV=dev mix release` and called with 146 | `MIX_ENV=prod myapp start`, secrets (and all other configuration) will be fetched from `dev` enviroment. 147 | 148 | So, to configure secrets in runtime, you can write something like: 149 | 150 | ``` 151 | # in config/runtime.exs 152 | import Config 153 | import SecretVault, only: [runtime_secret!: 2] 154 | 155 | config :playground, MyApp.Repo, 156 | password: runtime_secret!(:playground, "database_password") 157 | ``` 158 | 159 | ## Release 160 | 161 | There is no special behaviour for releases. Just `mix release` and 162 | use. Don't forget to add `mix scr.audit` task in your `CI` to enforce 163 | quality of passwords. 164 | -------------------------------------------------------------------------------- /guides/tutorials/umbrella.md: -------------------------------------------------------------------------------- 1 | # Umbrella tutorial 2 | 3 | This is a 6 minutes `SecretVault` tutorial that shows how to install 4 | and configure `SecretVault` for umbrella applications. 5 | 6 | ## Setup 7 | 8 | Create a separate dummy application in your apps directory like 9 | ```sh 10 | $ cd apps 11 | $ mix new secret_store 12 | $ cd secret_store 13 | ``` 14 | 15 | Remove lib and test directories and chagne README.md to reflect that 16 | this application is used solely for secret management 17 | 18 | ```sh 19 | $ rm -rf lib test 20 | $ mkdir priv 21 | $ echo "# SecretStore\n\nDummy storage for secrets" > README.md 22 | ``` 23 | 24 | ## Install 25 | 26 | Just add it into your `SecretStore`'s dependencies like 27 | ```elixir 28 | defp deps do 29 | [ 30 | {:secret_vault, "~> 1.0"} 31 | ] 32 | end 33 | ``` 34 | 35 | ## Configure 36 | 37 | Configuration is straightforward. Minimal configuration would look 38 | like this: 39 | 40 | ```elixir 41 | import Config 42 | 43 | config :secret_store, :secret_vault, 44 | default: [password: System.fetch_env!("SECRET_VAULT_PASSWORD")] 45 | 46 | # Here `default` is a name of a default prefix. Prefixes work like namespaces for secrets. 47 | ``` 48 | 49 | 50 | You can provide options other than 51 | `password`. To see a full list of those check out the 52 | `SecretVault.Config.new/2` documentation. 53 | 54 | > ### Note {: .info } 55 | > 56 | > Each `MIX_ENV` will have it's own separate vault, so don't use 57 | > prefixes to separate envs. 58 | 59 | ## Create secrets 60 | 61 | To create a secret during development, you can use one of many secret 62 | creating tasks available. But do not forget to provide the enviroment 63 | variable with the password which we specified above. 64 | 65 | For example (inside `apps/secret_store`), 66 | 67 | ```sh 68 | $ export SECRET_VAULT_PASSWORD="password" # Don't forget to change the password value 69 | $ mix scr.insert dev database_password "My Super Secret Password" 70 | ``` 71 | 72 | Or, to be able to write a password in your favourite editor, use 73 | 74 | ```sh 75 | $ mix scr.create dev database_password 76 | ``` 77 | 78 | Here `dev` defines the `MIX_ENV` for which you'd like to create a 79 | secret. And `database_password` is a name of the secret. These exact 80 | commands will create a secret in 81 | `priv/dev/default/database_password.vault_secret`. Each secret is 82 | written onto it's own file in vault directory in `priv`. This plays 83 | nice with version control systems and simplifies the user interface. 84 | 85 | > ### Note {: .info } 86 | > 87 | > Each secret is written into it's own file with `.vault_secret` 88 | > extension. Therefore, secrets' names must be suitable file names. 89 | 90 | ## Manipulate secrets 91 | 92 | To edit already created secrets, one can use `mix scr.insert` and `mix 93 | scr.edit` commands. To delete, rename or copy secrets, one 94 | can use regular coreutils like `mv`, `cp`, `rm`, since each secret is 95 | placed under the corresponding directory in `priv`. Usually the path 96 | is `priv/$MIX_ENV/$PREFIX/${SECRET_NAME}.vault_secret`. 97 | 98 | For example, to delete secret which was created in previous step, one 99 | could write: 100 | 101 | ```sh 102 | $ rm priv/dev/default/database_password.vault_secret 103 | ``` 104 | 105 | Or to rename a secret: 106 | ```sh 107 | $ cd priv/dev/default/ 108 | $ mv database_password.vault_secret db_password.vault_secret 109 | ``` 110 | 111 | ## Access secrets 112 | 113 | To access and use secrets from `Elixir` application, one first need to 114 | retrieve the vault configuration. Then, it is recommended to place the 115 | secrets into some runtime storage (like `persistent_term`, for 116 | example). 117 | 118 | So, regular workflow would look like 119 | 120 | ```elixir 121 | defmodule MyApp.Application do 122 | use Application 123 | 124 | def start(_type, _args) do 125 | ... 126 | {:ok, config} = SecretVault.Config.fetch_from_current_env(:my_app) 127 | SecretVault.Storage.to_persistent_term(config) 128 | end 129 | end 130 | ``` 131 | 132 | This will create config from configuration of your application and put 133 | all decrypted passwords in the `persistent_term`. See 134 | `SecretVault.Config` and `SecretVault.Storage` for more options. 135 | 136 | If you want to have you options in application env you can specify 137 | this in `config.exs` 138 | 139 | ```elixir 140 | # in config/config.exs 141 | import Config 142 | 143 | config :secret_store, :secret_vault, 144 | default: [password: System.fetch_env!("SECRET_VAULT_PASSWORD")] 145 | 146 | # in application.ex start function 147 | {:ok, config} = SecretVault.Config.fetch_from_current_env(:my_app) 148 | SecretVault.Storage.to_application_env(config) 149 | ``` 150 | 151 | ## Runtime configuration 152 | 153 | It is a common practice to configure application in configuration scripts like `config/config.exs`, `config/dev.exs` and `config/runtime.exs`. And there are two things 154 | a developer must keep in mind while working with them 155 | 156 | First of all, you **must not** use compile time configuration scritps (basically everything except `runtime.exs`) for setting values from secrets, since these values will appear in `app.src` file in your `_build` or release ebin directory. Usually, this is not something you can tolerate 157 | 158 | Second, `runtime.exs` config will be called during initialization of your project and the enviroment of the project will be inherited from the enviroment which was during project compilation. This means, that for release created with `MIX_ENV=dev mix release` and called with 159 | `MIX_ENV=prod myapp start`, secrets (and all other configuration) will be fetched from `dev` enviroment. 160 | 161 | So, to configure secrets in runtime, you can write something like: 162 | 163 | ``` 164 | # in config/runtime.exs 165 | import Config 166 | import SecretVault, only: [runtime_secret!: 2] 167 | 168 | config :secret_store, MyApp.Repo, 169 | password: runtime_secret!(:secret_store, "database_password") 170 | ``` 171 | 172 | ## Release 173 | 174 | There is no special behaviour for releases. Just `mix release` and 175 | use. Don't forget to add `mix scr.audit` task in your `CI` to enforce 176 | quality of passwords. 177 | -------------------------------------------------------------------------------- /lib/secret_vault/config.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault.Config do 2 | @moduledoc """ 3 | Configuration for a `SecretVault` vault. This configuration 4 | defines ciphers, their options, holds simmetric encryption key 5 | and path to the vault. 6 | 7 | You can set configuration as plain values like 8 | ```elixir 9 | config :my_app, :secret_vault, 10 | default: [password: "super secret password"] 11 | ``` 12 | 13 | Or like 14 | ```elixir 15 | config :my_app, :secret_vault, 16 | default: [password: {System, :get_env, "SUPER_SECRET_PASSWORD"}] 17 | ``` 18 | To fetch password in runtime with specified module function arity 19 | """ 20 | 21 | @struct_keys [:key, :env] ++ 22 | [ 23 | cipher: SecretVault.Cipher.ErlangCrypto, 24 | cipher_opts: [], 25 | priv_path: nil, 26 | prefix: "default" 27 | ] 28 | 29 | defstruct @struct_keys 30 | 31 | @typedoc """ 32 | A module implementing `SecretVault.EncryptionProvider` behaviour. 33 | """ 34 | @type cipher :: module 35 | 36 | @typedoc """ 37 | Options for specified cipher. 38 | """ 39 | @type cipher_opts :: Keyword.t() 40 | 41 | @typedoc """ 42 | A module implementing `SecretVault.KeyDerivation` 43 | """ 44 | @type key_derivation :: module 45 | 46 | @typedoc """ 47 | Options for specified key derivation function. 48 | """ 49 | @type key_derivation_opts :: Keyword.t() 50 | 51 | @typedoc """ 52 | Priv path. Use it only when you wan't to specify it by hands. 53 | """ 54 | @type priv_path :: String.t() 55 | 56 | @typedoc """ 57 | Path prefix for your secrets in priv directory. 58 | 59 | It's usefull when you want to have more than one secret storage. 60 | Defaults to `secrets`. 61 | """ 62 | @type prefix :: String.t() 63 | 64 | @typedoc """ 65 | Simmetric key for cipher 66 | """ 67 | @type key :: binary() 68 | 69 | @typedoc """ 70 | Plain string password. 71 | """ 72 | @type password :: String.t() 73 | 74 | @typedoc """ 75 | """ 76 | @type t :: %__MODULE__{ 77 | cipher_opts: cipher_opts(), 78 | cipher: cipher(), 79 | key: key(), 80 | env: String.t(), 81 | priv_path: priv_path(), 82 | prefix: prefix() 83 | } 84 | 85 | # For Mix projects we can have this variable in compile-time 86 | # For non-Mix projects we can specify this variable in runtime 87 | # or work without `env` path at all 88 | env = 89 | if Code.ensure_loaded?(Mix) && function_exported?(Mix, :env, 0) do 90 | to_string(Mix.env()) 91 | else 92 | "" 93 | end 94 | 95 | @typedoc """ 96 | Options to set for a config. 97 | """ 98 | @type config_option :: 99 | {:cipher, cipher} 100 | | {:cipher_opts, cipher_opts} 101 | | {:key_derivation, key_derivation} 102 | | {:key_derivation_opts, key_derivation_opts} 103 | | {:priv_path, priv_path} 104 | | {:prefix, prefix} 105 | | {:password, password()} 106 | | {:key, key()} 107 | | {:env, String.t()} 108 | 109 | @doc """ 110 | Creates a struct that keeps configuration data for the storage. 111 | 112 | `app_name` is an OTP application name for the app you want to 113 | keep secrets for. 114 | """ 115 | @spec new(app_name :: atom, [config_option]) :: t 116 | def new(app_name, opts \\ []) when is_atom(app_name) and is_list(opts) do 117 | key = 118 | cond do 119 | key = opts[:key] -> 120 | key 121 | 122 | password = opts[:password] -> 123 | key_derivation = 124 | Keyword.get(opts, :key_derivation, SecretVault.KDFs.PBKDF2) 125 | 126 | key_derivation_opts = Keyword.get(opts, :key_derivation_opts, []) 127 | key_derivation.kdf(password, key_derivation_opts) 128 | 129 | true -> 130 | raise "No password or key specified" 131 | end 132 | 133 | opts = 134 | opts 135 | |> Keyword.put_new_lazy(:priv_path, fn -> 136 | to_string(:code.priv_dir(app_name)) 137 | end) 138 | |> Keyword.put_new(:prefix, "secrets") 139 | |> Keyword.put_new(:env, to_string(unquote(env))) 140 | |> Keyword.put_new(:key, key) 141 | 142 | __MODULE__ 143 | |> struct([{:key, key} | opts]) 144 | |> check_priv() 145 | end 146 | 147 | @doc """ 148 | Same as `fetch_from_env/3`, but passes `env` authomatically. 149 | Fetch config from the application configuration (e.g. in 150 | `confix.exs`). 151 | 152 | `otp_app` is the current OTP application name. 153 | `prefix` must be one of the configured prefixes. 154 | """ 155 | @spec fetch_from_current_env(atom, prefix, [config_option]) :: 156 | {:ok, t} 157 | | {:error, {:no_configuration_for_app, otp_app :: module}} 158 | | {:error, {:no_configuration_for_prefix, prefix}} 159 | def fetch_from_current_env(otp_app, prefix \\ "default", opts \\ []) 160 | when is_atom(otp_app) and is_binary(prefix) do 161 | env = 162 | if Code.ensure_loaded?(Mix) && function_exported?(Mix, :env, 0) do 163 | to_string(Mix.env()) 164 | else 165 | unquote(env) 166 | end 167 | 168 | fetch_from_env(otp_app, env, prefix, opts) 169 | end 170 | 171 | @doc false 172 | @spec fetch_from_env(atom, String.t() | atom(), prefix, [config_option]) :: 173 | {:ok, t} 174 | | {:error, {:no_configuration_for_app, otp_app :: module}} 175 | | {:error, {:no_configuration_for_prefix, prefix}} 176 | def fetch_from_env(otp_app, env, prefix, opts \\ []) 177 | 178 | def fetch_from_env(otp_app, env, prefix, opts) when is_atom(env) do 179 | fetch_from_env(otp_app, to_string(env), prefix, opts) 180 | end 181 | 182 | def fetch_from_env(otp_app, env, prefix, opts) 183 | when is_atom(otp_app) and is_binary(env) and is_binary(prefix) do 184 | with {:ok, prefixes} <- fetch_application_env(otp_app), 185 | {:ok, env_opts} <- find_prefix(prefixes, prefix) do 186 | opts = 187 | env_opts 188 | |> Keyword.merge(opts) 189 | |> Keyword.put(:prefix, prefix) 190 | |> Enum.map(fn 191 | {key, {module, function, args}} -> 192 | {key, apply(module, function, args)} 193 | 194 | kv -> 195 | kv 196 | end) 197 | 198 | config = new(otp_app, opts) 199 | {:ok, %__MODULE__{config | env: env}} 200 | end 201 | end 202 | 203 | defp fetch_application_env(otp_app) do 204 | with :error <- Application.fetch_env(otp_app, :secret_vault) do 205 | {:error, {:no_configuration_for_app, otp_app}} 206 | end 207 | end 208 | 209 | defp find_prefix([], prefix) do 210 | {:error, {:no_configuration_for_prefix, prefix}} 211 | end 212 | 213 | defp find_prefix([{atom_prefix, opts} | rest], prefix) do 214 | case to_string(atom_prefix) do 215 | ^prefix -> {:ok, opts} 216 | _ -> find_prefix(rest, prefix) 217 | end 218 | end 219 | 220 | # This function is required primarily for doctests 221 | if Code.ensure_loaded?(Mix) and function_exported?(Mix, :env, 0) and 222 | Mix.env() == :test do 223 | def test_config do 224 | new(:secret_vault, 225 | password: "123456", 226 | prefix: "#{:erlang.unique_integer([:positive])}" 227 | ) 228 | end 229 | end 230 | 231 | struct_opts = 232 | Enum.map(@struct_keys, fn 233 | {k, _} -> k 234 | k -> k 235 | end) 236 | 237 | all_opts = 238 | [:password] ++ 239 | (struct_opts -- [:cipher_opts, :key_derivation_opts, :key, :env]) 240 | 241 | @doc """ 242 | Return list of options available for config. 243 | """ 244 | @spec available_options :: [atom] 245 | def available_options do 246 | unquote(all_opts) 247 | end 248 | 249 | defp check_priv(%__MODULE__{priv_path: priv} = config) do 250 | if Code.ensure_loaded?(Mix) do 251 | with {:ok, %File.Stat{type: type}} when type != :symlink <- 252 | File.lstat(priv) do 253 | IO.warn(""" 254 | It looks like `priv` directory is inside `_build`. and it is not linked to `priv` in root 255 | of your project. If you have called this task while `_build` path is not present, it's okay. 256 | Otherwise, you need to avoid this. Please, 257 | 258 | 1. Remove `_build` directory 259 | 2. Create `priv` in root of your project 260 | """) 261 | end 262 | end 263 | 264 | config 265 | end 266 | end 267 | -------------------------------------------------------------------------------- /lib/secret_vault.ex: -------------------------------------------------------------------------------- 1 | defmodule SecretVault do 2 | @moduledoc """ 3 | Runtime interface to manipulate on-disk secrets. 4 | """ 5 | 6 | alias SecretVault.Config 7 | 8 | defmodule Error do 9 | @moduledoc """ 10 | Exception for bang functions in `SecretVault`. 11 | """ 12 | defexception [:message, :reason] 13 | end 14 | 15 | @typedoc """ 16 | - `:unknown_prefix` means that directory with secrets is not present on disk 17 | - `:secret_not_found` means that secret file itself is not present 18 | """ 19 | @type reason :: 20 | {:unknown_prefix, Config.prefix(), env :: String.t()} 21 | | {:secret_not_found, name :: String.t(), env :: String.t()} 22 | 23 | @typedoc """ 24 | Name of a secret 25 | """ 26 | @type name :: String.t() 27 | 28 | @typedoc """ 29 | Binary value you want to store in secret. 30 | To store arbitary structures, try usings `:erlang.term_to_binary/2` 31 | """ 32 | @type value :: binary() 33 | 34 | extension = ".vault_secret" 35 | 36 | @doc """ 37 | Show all secrets' names available. It reads secrets from directory specified by `config` 38 | and retruns a list of names with no particular order. 39 | 40 | Example: 41 | iex> config = SecretVault.Config.test_config 42 | iex> SecretVault.put(config, "db_password", "super_secret_password") 43 | iex> SecretVault.put(config, "admin_password", "another_password") 44 | iex> {:ok, names} = SecretVault.list(config) 45 | iex> "db_password" in names 46 | true 47 | iex> "admin_password" in names 48 | true 49 | """ 50 | @spec list(Config.t()) :: {:ok, [String.t()]} | {:error, :unknown_prefix} 51 | def list(%Config{} = config) do 52 | case File.ls(resolve_environment_path(config)) do 53 | {:ok, files} -> 54 | files = 55 | files 56 | |> Enum.reject(&String.starts_with?(&1, ".")) 57 | |> Enum.map(fn filename -> 58 | {name, unquote(extension)} = 59 | String.split_at(filename, -unquote(byte_size(extension))) 60 | 61 | name 62 | end) 63 | 64 | {:ok, files} 65 | 66 | {:error, _} -> 67 | {:error, {:unknown_prefix, config.prefix, config.env}} 68 | end 69 | end 70 | 71 | @doc """ 72 | Put `data` as a value of the secret `name` using the `config`. This function 73 | writes encrypted data to the disk, therefore use this with caution. If you 74 | want to write data in runtime, it is recommended to create singleton 75 | process to perform mutating operations 76 | 77 | Example: 78 | iex> config = SecretVault.Config.test_config 79 | iex> SecretVault.put(config, "db_password", "super_secret_password") 80 | iex> SecretVault.get(config, "db_password") 81 | "super_secret_password" 82 | """ 83 | @spec put(Config.t(), name(), value()) :: :ok | {:error, File.posix()} 84 | def put(%Config{} = config, name, data) 85 | when is_binary(name) and is_binary(data) do 86 | encrypted_data = 87 | config.cipher.encrypt( 88 | config.key, 89 | data, 90 | config.cipher_opts 91 | ) 92 | 93 | path = resolve_environment_path(config) 94 | file_path = resolve_secret_path(config, name) 95 | 96 | with :ok <- File.mkdir_p(path) do 97 | File.write(file_path, encrypted_data) 98 | end 99 | end 100 | 101 | @doc """ 102 | Get a clear text value of the secret `name` using the `config`. Reads 103 | a data from disk storage, decrypts it, and returns the default if secret was not found. 104 | 105 | Example: 106 | iex> config = SecretVault.Config.test_config 107 | iex> SecretVault.put(config, "db_password", "super_secret_password") 108 | iex> SecretVault.get(config, "db_password") 109 | "super_secret_password" 110 | iex> SecretVault.get(config, "non_present_password") 111 | "" 112 | """ 113 | @spec get(Config.t(), name(), default :: value()) :: value() 114 | def get(%Config{} = config, name, default \\ "") do 115 | case fetch(config, name) do 116 | {:ok, data} -> 117 | data 118 | 119 | {:error, _reason} -> 120 | default 121 | end 122 | end 123 | 124 | @doc """ 125 | Fetch a clear text value of the secret `name` using the `config`. Reads 126 | a data from disk storage, decrypts it, and returns the result of an operation 127 | in an "either" manner. 128 | 129 | Example: 130 | iex> config = SecretVault.Config.test_config 131 | iex> SecretVault.put(config, "db_password", "super_secret_password") 132 | iex> SecretVault.fetch(config, "db_password") 133 | {:ok, "super_secret_password"} 134 | iex> SecretVault.fetch(config, "non_present_password") 135 | {:error, {:secret_not_found, "non_present_password", "test"}} 136 | """ 137 | @spec fetch(Config.t(), name) :: {:ok, value} | {:error, error} 138 | when error: reason | :invalid_encryption_key 139 | def fetch(%Config{} = config, name) when is_binary(name) do 140 | at_path(config, name, fn file_path -> 141 | encrypted_data = File.read!(file_path) 142 | 143 | config.cipher.decrypt( 144 | config.key, 145 | encrypted_data, 146 | config.cipher_opts 147 | ) 148 | end) 149 | end 150 | 151 | @doc """ 152 | Fetch a clear text value of the secret `name` using the `config`. 153 | 154 | Fetch a clear text value of the secret `name` using the `config`. Reads 155 | a data from disk storage, decrypts it, and returns the decrypted data or 156 | raises if no secret with the `name` found. 157 | 158 | Example: 159 | iex> config = SecretVault.Config.test_config 160 | iex> SecretVault.put(config, "db_password", "super_secret_password") 161 | iex> SecretVault.fetch!(config, "db_password") 162 | "super_secret_password" 163 | """ 164 | @spec fetch!(Config.t(), name()) :: value() 165 | def fetch!(%Config{} = config, name) do 166 | case fetch(config, name) do 167 | {:ok, data} -> 168 | data 169 | 170 | # TODO 171 | {:error, reason} -> 172 | raise Error, message: "Couldn't fetch the secret", reason: reason 173 | end 174 | end 175 | 176 | @doc """ 177 | Asynchronously fetches all secrets from the vault specified by the `config` from disk. 178 | This function returns a map or error in "either" manner. 179 | 180 | Example: 181 | iex> config = SecretVault.Config.test_config 182 | iex> SecretVault.put(config, "db_password", "super_secret_password") 183 | iex> SecretVault.put(config, "admin_password", "another_password") 184 | iex> SecretVault.fetch_all(config) 185 | {:ok, %{"db_password" => "super_secret_password", "admin_password" => "another_password"}} 186 | """ 187 | @spec fetch_all(Config.t()) :: 188 | {:ok, %{name() => value()}} 189 | | {:error, {name(), reason()}} 190 | | {:error, reason()} 191 | def fetch_all(%Config{} = config) do 192 | at_all_names(config, {:ok, %{}}, fn name, value, {:ok, acc} -> 193 | {:cont, {:ok, Map.put(acc, name, value)}} 194 | end) 195 | end 196 | 197 | @doc """ 198 | Remove secret `name` from the vault specified by the `config` from disk. 199 | 200 | Example: 201 | iex> config = SecretVault.Config.test_config 202 | iex> SecretVault.put(config, "db_password", "super_secret_password") 203 | iex> SecretVault.delete(config, "db_password") 204 | iex> SecretVault.fetch(config, "db_password") 205 | {:error, {:secret_not_found, "db_password", "test"}} 206 | """ 207 | @spec delete(Config.t(), name()) :: :ok | {:error, reason()} 208 | def delete(%Config{} = config, name) when is_binary(name) do 209 | at_path(config, name, &File.rm/1) 210 | end 211 | 212 | @doc """ 213 | Tells whether the secret `name` exists. 214 | 215 | Example: 216 | iex> config = SecretVault.Config.test_config 217 | iex> SecretVault.put(config, "db_password", "super_secret_password") 218 | iex> SecretVault.exists?(config, "db_password") 219 | true 220 | iex> SecretVault.exists?(config, "non_present_password") 221 | false 222 | """ 223 | @spec exists?(Config.t(), name()) :: boolean() 224 | def exists?(config, name) do 225 | case at_path(config, name, &{:ok, &1}) do 226 | {:ok, _} -> true 227 | {:error, _} -> false 228 | end 229 | end 230 | 231 | @doc """ 232 | Helper macro for getting secrets in `config/runtime.exs` file. 233 | 234 | ## Example 235 | 236 | ``` 237 | # in `config/runtime.exs` 238 | import Config 239 | import SecretVault, only: [runtime_secret!: 2] 240 | 241 | config :my_app, MyApp.Repo, 242 | password: runtime_secret!(:my_app, "database_password") 243 | ``` 244 | 245 | For a list of available options, see `t:SecretVault.Config.config_option/0` 246 | """ 247 | defmacro runtime_secret!(app_name, name, opts \\ []) do 248 | quote bind_quoted: [app_name: app_name, name: name, opts: opts] do 249 | require alias!(Config) 250 | {prefix, opts} = Keyword.pop(opts, :prefix, "default") 251 | 252 | {:ok, conf} = 253 | SecretVault.Config.fetch_from_env( 254 | app_name, 255 | alias!(Config).config_env(), 256 | prefix, 257 | opts 258 | ) 259 | 260 | SecretVault.fetch!(conf, name) 261 | end 262 | end 263 | 264 | @doc """ 265 | Like `SecretVault.runtime_secret!/3` but accepts default value when secret is not found 266 | as the third parameter. 267 | """ 268 | defmacro runtime_secret(app_name, name, default \\ nil, opts \\ []) do 269 | quote bind_quoted: [ 270 | app_name: app_name, 271 | name: name, 272 | default: default, 273 | opts: opts 274 | ] do 275 | require alias!(Config) 276 | {prefix, opts} = Keyword.pop(opts, :prefix, "default") 277 | 278 | {:ok, conf} = 279 | SecretVault.Config.fetch_from_env( 280 | app_name, 281 | alias!(Config).config_env(), 282 | prefix, 283 | opts 284 | ) 285 | 286 | SecretVault.get(conf, name, default) 287 | end 288 | end 289 | 290 | # Resolves a path to the `name` secret 291 | @doc false 292 | @spec resolve_secret_path(Config.t(), name()) :: Path.t() 293 | def resolve_secret_path(%Config{} = config, name) when is_binary(name) do 294 | file_name = name <> unquote(extension) 295 | Path.join([resolve_environment_path(config), file_name]) 296 | end 297 | 298 | # Resolves a path to the prefixed directory with secrets 299 | @doc false 300 | @spec resolve_environment_path(Config.t()) :: Path.t() 301 | def resolve_environment_path(config) do 302 | %Config{priv_path: priv_path, env: env, prefix: prefix} = config 303 | Path.join([priv_path, "secret_vault", env, prefix]) 304 | end 305 | 306 | # Helpers 307 | 308 | @spec at_path(Config.t(), name(), (Path.t() -> any())) :: any() 309 | defp at_path(config, name, closure) do 310 | path = resolve_environment_path(config) 311 | file_path = resolve_secret_path(config, name) 312 | 313 | cond do 314 | File.exists?(file_path) -> 315 | closure.(file_path) 316 | 317 | not File.exists?(path) -> 318 | {:error, {:unknown_prefix, config.prefix, config.env}} 319 | 320 | true -> 321 | {:error, {:secret_not_found, name, config.env}} 322 | end 323 | end 324 | 325 | @spec at_all_names( 326 | Config.t(), 327 | acc, 328 | (name(), value(), acc -> {:cont, acc} | {:halt, res}) 329 | ) :: acc | res | {:error, {name, reason}} 330 | when acc: any(), res: any() 331 | defp at_all_names(config, acc, closure) do 332 | with {:ok, list} <- list(config) do 333 | list 334 | |> Task.async_stream( 335 | fn name -> {name, fetch(config, name)} end, 336 | # Because file operations are concurrent 337 | max_concurrency: System.schedulers_online() * 8, 338 | ordered: false 339 | ) 340 | |> Enum.reduce_while(acc, fn 341 | {:ok, {name, {:ok, value}}}, acc -> 342 | closure.(name, value, acc) 343 | 344 | {:ok, {name, {:error, reason}}}, _acc -> 345 | {:halt, {:error, {name, reason}}} 346 | end) 347 | end 348 | end 349 | end 350 | --------------------------------------------------------------------------------