diff --git a/lib/cloak/padding.ex b/lib/cloak/padding.ex new file mode 100644 index 0000000..c320479 --- /dev/null +++ b/lib/cloak/padding.ex @@ -0,0 +1,15 @@ +defmodule Cloak.Padding do + def pad(str, size \\ 16) do + if byte_size(str) < size do + str + |> Kernel.<>("\x80") + |> String.pad_trailing(size - 1, "\x00") + else + str + end + end + + def unpad(str) do + String.replace(str, ~r/\x80[\x00]+$/, "") + end +end diff --git a/lib/cloak/vault.ex b/lib/cloak/vault.ex index 1bcd659..484d4d6 100644 --- a/lib/cloak/vault.ex +++ b/lib/cloak/vault.ex @@ -121,6 +121,7 @@ defmodule Cloak.Vault do @type plaintext :: binary @type ciphertext :: binary @type label :: atom + @type opts :: Keyword.t() @doc """ Encrypts a binary using the first configured cipher in the vault's @@ -128,33 +129,45 @@ defmodule Cloak.Vault do """ @callback encrypt(plaintext) :: {:ok, ciphertext} | {:error, Exception.t()} + @callback encrypt(plaintext, opts) :: {:ok, ciphertext} | {:error, Exception.t()} + @doc """ Like `encrypt/1`, but raises any errors. """ @callback encrypt!(plaintext) :: ciphertext | no_return + @callback encrypt!(plaintext, opts) :: ciphertext | no_return + @doc """ Encrypts a binary using the vault's configured cipher with the corresponding label. """ @callback encrypt(plaintext, label) :: {:ok, ciphertext} | {:error, Exception.t()} + @callback encrypt(plaintext, label, opts) :: {:ok, ciphertext} | {:error, Exception.t()} + @doc """ Like `encrypt/2`, but raises any errors. """ @callback encrypt!(plaintext, label) :: ciphertext | no_return + @callback encrypt!(plaintext, label, opts) :: ciphertext | no_return + @doc """ Decrypts a binary with the configured cipher that generated the binary. Automatically detects which cipher to use, based on the ciphertext. """ @callback decrypt(ciphertext) :: {:ok, plaintext} | {:error, Exception.t()} + @callback decrypt(ciphertext, opts) :: {:ok, plaintext} | {:error, Exception.t()} + @doc """ Like `decrypt/1`, but raises any errors. """ @callback decrypt!(ciphertext) :: plaintext | no_return + @callback decrypt!(ciphertext, opts) :: plaintext | no_return + @doc """ The JSON library the vault uses to convert maps and lists into JSON binaries before encryption. @@ -220,44 +233,64 @@ defmodule Cloak.Vault do @impl Cloak.Vault def encrypt(plaintext) do + encrypt(plaintext, []) + end + + @impl Cloak.Vault + def encrypt(plaintext, opts) when is_list(opts) do @table_name |> Cloak.Vault.read_config() - |> Cloak.Vault.encrypt(plaintext) + |> Cloak.Vault.encrypt(plaintext, opts) end @impl Cloak.Vault def encrypt!(plaintext) do + encrypt!(plaintext, []) + end + + @impl Cloak.Vault + def encrypt!(plaintext, opts) when is_list(opts) do @table_name |> Cloak.Vault.read_config() - |> Cloak.Vault.encrypt!(plaintext) + |> Cloak.Vault.encrypt!(plaintext, opts) end @impl Cloak.Vault - def encrypt(plaintext, label) do + def encrypt(plaintext, label, opts \\ []) do @table_name |> Cloak.Vault.read_config() - |> Cloak.Vault.encrypt(plaintext, label) + |> Cloak.Vault.encrypt(plaintext, label, opts) end @impl Cloak.Vault - def encrypt!(plaintext, label) do + def encrypt!(plaintext, label, opts \\ []) do @table_name |> Cloak.Vault.read_config() - |> Cloak.Vault.encrypt!(plaintext, label) + |> Cloak.Vault.encrypt!(plaintext, label, opts) end @impl Cloak.Vault def decrypt(ciphertext) do + decrypt(ciphertext, []) + end + + @impl Cloak.Vault + def decrypt(ciphertext, opts) do @table_name |> Cloak.Vault.read_config() - |> Cloak.Vault.decrypt(ciphertext) + |> Cloak.Vault.decrypt(ciphertext, opts) end @impl Cloak.Vault def decrypt!(ciphertext) do + decrypt!(ciphertext, []) + end + + @impl Cloak.Vault + def decrypt!(ciphertext, opts) do @table_name |> Cloak.Vault.read_config() - |> Cloak.Vault.decrypt!(ciphertext) + |> Cloak.Vault.decrypt!(ciphertext, opts) end @impl Cloak.Vault @@ -292,9 +325,13 @@ defmodule Cloak.Vault do end @doc false - def encrypt(config, plaintext) do - with [{_label, {module, opts}} | _ciphers] <- config[:ciphers] do - module.encrypt(plaintext, opts) + def encrypt(config, plaintext, opts) do + padding = Keyword.get(opts, :padding, false) + + with [{_label, {module, module_opts}} | _ciphers] <- config[:ciphers] do + plaintext + |> maybe_pad_plaintext(padding) + |> module.encrypt(module_opts) else _ -> {:error, Cloak.InvalidConfig.exception("could not encrypt due to missing configuration")} @@ -302,8 +339,8 @@ defmodule Cloak.Vault do end @doc false - def encrypt!(config, plaintext) do - case encrypt(config, plaintext) do + def encrypt!(config, plaintext, opts) do + case encrypt(config, plaintext, opts) do {:ok, ciphertext} -> ciphertext @@ -313,19 +350,23 @@ defmodule Cloak.Vault do end @doc false - def encrypt(config, plaintext, label) do + def encrypt(config, plaintext, label, opts) do + padding = Keyword.get(opts, :padding, false) + case config[:ciphers][label] do nil -> {:error, Cloak.MissingCipher.exception(vault: config[:vault], label: label)} - {module, opts} -> - module.encrypt(plaintext, opts) + {module, module_opts} -> + plaintext + |> maybe_pad_plaintext(padding) + |> module.encrypt(module_opts) end end @doc false - def encrypt!(config, plaintext, label) do - case encrypt(config, plaintext, label) do + def encrypt!(config, plaintext, label, opts) do + case encrypt(config, plaintext, label, opts) do {:ok, ciphertext} -> ciphertext @@ -334,20 +375,35 @@ defmodule Cloak.Vault do end end + defp maybe_pad_plaintext(plaintext, false) do + plaintext + end + + defp maybe_pad_plaintext(plaintext, size) when is_integer(size) do + Cloak.Padding.pad(plaintext, size) + end + + defp maybe_pad_plaintext(plaintext, _padding) do + Cloak.Padding.pad(plaintext) + end + @doc false - def decrypt(config, ciphertext) do + def decrypt(config, ciphertext, opts) do + padding = Keyword.get(opts, :padding, false) + case find_module_to_decrypt(config, ciphertext) do nil -> {:error, Cloak.MissingCipher.exception(vault: config[:vault], ciphertext: ciphertext)} - {_label, {module, opts}} -> - module.decrypt(ciphertext, opts) + {_label, {module, module_opts}} -> + {:ok, maybe_padded_plaintext} = module.decrypt(ciphertext, module_opts) + maybe_unpad_ciphertext(maybe_padded_plaintext, padding) end end @doc false - def decrypt!(config, ciphertext) do - case decrypt(config, ciphertext) do + def decrypt!(config, ciphertext, opts) do + case decrypt(config, ciphertext, opts) do {:ok, plaintext} -> plaintext @@ -356,9 +412,17 @@ defmodule Cloak.Vault do end end + defp maybe_unpad_ciphertext(ciphertext, false) do + {:ok, ciphertext} + end + + defp maybe_unpad_ciphertext(ciphertext, _padding) do + {:ok, Cloak.Padding.unpad(ciphertext)} + end + defp find_module_to_decrypt(config, ciphertext) do - Enum.find(config[:ciphers], fn {_label, {module, opts}} -> - module.can_decrypt?(ciphertext, opts) + Enum.find(config[:ciphers], fn {_label, {module, module_opts}} -> + module.can_decrypt?(ciphertext, module_opts) end) end end diff --git a/test/cloak/vault_test.exs b/test/cloak/vault_test.exs index 1c9ef6b..07c182f 100644 --- a/test/cloak/vault_test.exs +++ b/test/cloak/vault_test.exs @@ -75,6 +75,19 @@ defmodule Cloak.VaultTest do describe ".encrypt/2" do test "encrypts ciphertext with the cipher associated with label" do assert {:ok, ciphertext} = TestVault.encrypt("plaintext", :secondary) + assert is_binary(ciphertext) + assert ciphertext != "plaintext" + end + + test "encrypts ciphertext with default padding" do + assert {:ok, ciphertext} = TestVault.encrypt("plaintext", padding: true) + assert is_binary(ciphertext) + assert ciphertext != "plaintext" + end + + test "encrypts ciphertext with custom padding" do + assert {:ok, ciphertext} = TestVault.encrypt("plaintext", padding: 24) + assert is_binary(ciphertext) assert ciphertext != "plaintext" end @@ -90,6 +103,18 @@ defmodule Cloak.VaultTest do assert ciphertext != "plaintext" end + test "encrypts ciphertext with default padding" do + assert ciphertext = TestVault.encrypt!("plaintext", padding: true) + assert is_binary(ciphertext) + assert ciphertext != "plaintext" + end + + test "encrypts ciphertext with custom padding" do + assert ciphertext = TestVault.encrypt!("plaintext", padding: 24) + assert is_binary(ciphertext) + assert ciphertext != "plaintext" + end + test "raises error if no cipher associated with label" do assert_raise Cloak.MissingCipher, fn -> TestVault.encrypt!("plaintext", :nonexistent) @@ -97,6 +122,45 @@ defmodule Cloak.VaultTest do end end + describe ".encrypt/3" do + test "encrypts ciphertext with the cipher associated with label and default padding" do + assert {:ok, ciphertext} = TestVault.encrypt("plaintext", :secondary, padding: true) + assert is_binary(ciphertext) + assert ciphertext != "plaintext" + end + + test "encrypts ciphertext with the cipher associated with label and custom padding" do + assert {:ok, ciphertext} = TestVault.encrypt("plaintext", :secondary, padding: 24) + assert is_binary(ciphertext) + assert ciphertext != "plaintext" + end + + test "returns error if no cipher associated with label" do + assert {:error, %Cloak.MissingCipher{}} = + TestVault.encrypt("plaintext", :nonexistent, padding: true) + end + end + + describe ".encrypt!/3" do + test "encrypts ciphertext with the cipher associated with label and default padding" do + ciphertext = TestVault.encrypt!("plaintext", :secondary, padding: true) + assert is_binary(ciphertext) + assert ciphertext != "plaintext" + end + + test "encrypts ciphertext with the cipher associated with label and custom padding" do + assert ciphertext = TestVault.encrypt!("plaintext", :secondary, padding: 32) + assert is_binary(ciphertext) + assert ciphertext != "plaintext" + end + + test "raises error if no cipher associated with label" do + assert_raise Cloak.MissingCipher, fn -> + TestVault.encrypt!("plaintext", :nonexistent, padding: true) + end + end + end + describe ".decrypt/1" do test "decrypts ciphertext" do {:ok, ciphertext1} = TestVault.encrypt("plaintext") @@ -111,7 +175,7 @@ defmodule Cloak.VaultTest do end end - describe ".decrypt!" do + describe ".decrypt!/1" do test "decrypts ciphertext" do ciphertext1 = TestVault.encrypt!("plaintext") ciphertext2 = TestVault.encrypt!("plaintext", :secondary) @@ -127,6 +191,74 @@ defmodule Cloak.VaultTest do end end + describe ".decrypt/2" do + test "decrypts ciphertext with default padding" do + {:ok, ciphertext1} = TestVault.encrypt("plaintext", padding: true) + {:ok, ciphertext2} = TestVault.encrypt("plaintext", :secondary, padding: true) + + assert {:ok, "plaintext"} = TestVault.decrypt(ciphertext1, padding: true) + assert {:ok, "plaintext"} = TestVault.decrypt(ciphertext2, padding: true) + end + + test "decrypts ciphertext with custom padding" do + {:ok, ciphertext1} = TestVault.encrypt("plaintext", padding: 24) + {:ok, ciphertext2} = TestVault.encrypt("plaintext", :secondary, padding: 24) + + assert {:ok, "plaintext"} = TestVault.decrypt(ciphertext1, padding: 24) + assert {:ok, "plaintext"} = TestVault.decrypt(ciphertext2, padding: 24) + end + + test "decrypts ciphertext but skips unpadding" do + {:ok, ciphertext1} = TestVault.encrypt("plaintext", padding: true) + {:ok, ciphertext2} = TestVault.encrypt("plaintext", :secondary, padding: true) + + assert {:ok, "plaintext\x80\x00\x00\x00\x00\x00"} = + TestVault.decrypt(ciphertext1, padding: false) + + assert {:ok, "plaintext\x80\x00\x00\x00\x00\x00"} = + TestVault.decrypt(ciphertext2, padding: false) + end + + test "returns error if no module found to decrypt" do + assert {:error, %Cloak.MissingCipher{}} = TestVault.decrypt(<<123, 123>>) + end + end + + describe ".decrypt!/2" do + test "decrypts ciphertext with default padding" do + ciphertext1 = TestVault.encrypt!("plaintext", padding: true) + ciphertext2 = TestVault.encrypt!("plaintext", :secondary, padding: true) + + assert "plaintext" == TestVault.decrypt!(ciphertext1, padding: true) + assert "plaintext" == TestVault.decrypt!(ciphertext2, padding: true) + end + + test "decrypts ciphertext with custom padding" do + ciphertext1 = TestVault.encrypt!("plaintext", padding: 24) + ciphertext2 = TestVault.encrypt!("plaintext", :secondary, padding: 24) + + assert "plaintext" == TestVault.decrypt!(ciphertext1, padding: 24) + assert "plaintext" == TestVault.decrypt!(ciphertext2, padding: 24) + end + + test "decrypts ciphertext but skips unpadding" do + ciphertext1 = TestVault.encrypt!("plaintext", padding: true) + ciphertext2 = TestVault.encrypt!("plaintext", :secondary, padding: true) + + assert "plaintext\x80\x00\x00\x00\x00\x00" == + TestVault.decrypt!(ciphertext1, padding: false) + + assert "plaintext\x80\x00\x00\x00\x00\x00" == + TestVault.decrypt!(ciphertext2, padding: false) + end + + test "raises error if no module found to decrypt" do + assert_raise Cloak.MissingCipher, fn -> + TestVault.decrypt!(<<123, 123>>) + end + end + end + describe ".json_library/1" do test "returns Jason by default" do assert TestVault.json_library() == Jason