Skip to content

Commit

Permalink
proof of concept of password-derived keys for bonfire-networks/bonfir…
Browse files Browse the repository at this point in the history
  • Loading branch information
mayel committed Oct 18, 2024
1 parent c7e232b commit 36aa14a
Show file tree
Hide file tree
Showing 3 changed files with 209 additions and 1 deletion.
115 changes: 115 additions & 0 deletions lib/crypto.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
defmodule Bonfire.Common.Crypto do
import Untangle
alias Bonfire.Common.Config
alias Bonfire.Common.Extend

#  NOTE: do not change once used, otherwise users won't be able to decrypt existing secrets
@default_algo :chacha
# TODO: put all in config
# for current number of recommended iterations see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
@iterations 600_000
@derived_key_length 32
@gcm_tag "AES.GCM.V1"
@iv_length 12

# encrypts some text with a password
def encrypt_with_auth_key(clear_text, password) do
# NOTE: salt should be a unique salt per-user, and saved to be used for decryption later.
salt = :crypto.strong_rand_bytes(16)

with {:ok, encrypted} <- encrypt_with_auth_key(clear_text, password, salt) do
# Return the encrypted PEM, salt, and any other necessary data
{:ok,
%{
encrypted: encrypted,
salt: salt
}}
else
e ->
error(e, "Encryption failed")
end
end

def encrypt_with_auth_key(clear_text, password, salt) do
# Derive a secret auth key from the password and salt
secret_auth_key = derive_key(password, salt)

cond do
algo() == :chacha and Extend.module_exists?(Plug.Crypto.MessageEncryptor) ->
# optionally use XChaCha20-Poly1305 `Plug.Crypto`?
{:ok, Plug.Crypto.MessageEncryptor.encrypt(clear_text, secret_auth_key, "")}

Extend.module_exists?(Cloak.Ciphers.AES.GCM) ->
# use AES GCM encryption
Cloak.Ciphers.AES.GCM.encrypt(clear_text,
key: secret_auth_key,
tag: @gcm_tag,
iv_length: @iv_length
)

true ->
error("No encryption library available")
end
end

# Function to decrypt the RSA PEM using password
def decrypt_with_auth_key(encrypted, password, salt) do
# Derive the secret auth key again from the password and salt
secret_auth_key = derive_key(password, salt)

# Decrypt the encrypted PEM using Cloak's AES GCM decryption
case do_decrypt(encrypted, secret_auth_key) do
{:ok, :error} ->
error("Unexpected decryption error, maybe the password or salt was incorrect?")

{:ok, decrypted} ->
{:ok, decrypted}

:error ->
error("Decryption error")

e ->
error(e, "Decryption error")
end
end

defp do_decrypt(encrypted, secret_auth_key) do
cond do
algo() == :chacha and Extend.module_exists?(Plug.Crypto.MessageEncryptor) ->
# optionally use XChaCha20-Poly1305 `Plug.Crypto`?
Plug.Crypto.MessageEncryptor.decrypt(encrypted, secret_auth_key, "")

Extend.module_exists?(Cloak.Ciphers.AES.GCM) ->
# use AES GCM encryption
Cloak.Ciphers.AES.GCM.decrypt(encrypted,
key: secret_auth_key,
tag: @gcm_tag,
iv_length: @iv_length
)

true ->
error("No encryption library available")
end
end

# Derives a key using PBKDF2-HMAC from the password and salt
defp derive_key(password, salt) do
if Extend.module_exists?(Plug.Crypto.KeyGenerator) do
# use helper function from `Plug.Crypto` if available
Plug.Crypto.KeyGenerator.generate(password, salt,
iterations: @iterations,
length: @derived_key_length
)
else
:crypto.pbkdf2_hmac(:sha256, password, salt, @iterations, @derived_key_length)
end
end

defp algo do
crypt_conf(:algo, @default_algo)
end

defp crypt_conf(key, default) do
Config.get([__MODULE__, key], default)
end
end
4 changes: 3 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ defmodule Bonfire.Common.MixProject do
# needed for graphql client, eg github for changelog
{:neuron, "~> 5.0", optional: true},
# for extension install + mix tasks that do patching
{:igniter, "~> 0.3", optional: true}
{:igniter, "~> 0.3", optional: true},
# for encryption
{:cloak, "~> 1.1.4", optional: true}
])
]
end
Expand Down
91 changes: 91 additions & 0 deletions test/common/crypto_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
defmodule Bonfire.Common.Crypto.Test do
use Bonfire.Common.DataCase, async: true
alias Bonfire.Common.Crypto
alias ActivityPub.Safety.Keys

@valid_password "correct_password"
@invalid_password "wrong_password"

test "encrypt_with_auth_key returns properly structured result" do
{:ok, rsa_pem} = Keys.generate_rsa_pem()

assert {:ok,
%{
encrypted: encrypted,
salt: salt
}} = Crypto.encrypt_with_auth_key(rsa_pem, @valid_password)

assert is_binary(encrypted)
assert byte_size(salt) == 16
end

test "decryption succeeds with correct password" do
{:ok, rsa_pem} = Keys.generate_rsa_pem()

assert {:ok, %{encrypted: encrypted, salt: salt}} =
Crypto.encrypt_with_auth_key(rsa_pem, @valid_password)

assert {:ok, decrypted_rsa_pem} =
Crypto.decrypt_with_auth_key(encrypted, @valid_password, salt)

assert decrypted_rsa_pem == rsa_pem
end

test "decryption fails with incorrect password" do
{:ok, rsa_pem} = Keys.generate_rsa_pem()

assert {:ok, %{encrypted: encrypted, salt: salt}} =
Crypto.encrypt_with_auth_key(rsa_pem, @valid_password)

assert {:error, _} = Crypto.decrypt_with_auth_key(encrypted, @invalid_password, salt)
end

test "decryption fails if ciphertext is modified" do
{:ok, rsa_pem} = Keys.generate_rsa_pem()

assert {:ok, %{encrypted: encrypted, salt: salt}} =
Crypto.encrypt_with_auth_key(rsa_pem, @valid_password)

# Modify the ciphertext slightly
modified_encrypted = <<0>> <> encrypted

assert {:error, _} = Crypto.decrypt_with_auth_key(modified_encrypted, @valid_password, salt)
end

test "key derivation is consistent" do
{:ok, rsa_pem} = Keys.generate_rsa_pem()

assert {:ok, %{encrypted: encrypted, salt: salt}} =
Crypto.encrypt_with_auth_key(rsa_pem, @valid_password)

assert {:ok, decrypted_rsa_pem1} =
Crypto.decrypt_with_auth_key(encrypted, @valid_password, salt)

assert {:ok, decrypted_rsa_pem2} =
Crypto.decrypt_with_auth_key(encrypted, @valid_password, salt)

# Ensure the same password/salt produces the same decryption result
assert decrypted_rsa_pem1 == decrypted_rsa_pem2
end

test "re-encrypting produces different ciphertext but decrypts to same value" do
{:ok, rsa_pem} = Keys.generate_rsa_pem()

assert {:ok, %{encrypted: encrypted1, salt: salt1}} =
Crypto.encrypt_with_auth_key(rsa_pem, @valid_password)

assert {:ok, %{encrypted: encrypted2, salt: salt2}} =
Crypto.encrypt_with_auth_key(rsa_pem, @valid_password)

assert encrypted1 != encrypted2
assert salt1 != salt2

assert {:ok, decrypted_rsa_pem1} =
Crypto.decrypt_with_auth_key(encrypted1, @valid_password, salt1)

assert {:ok, decrypted_rsa_pem2} =
Crypto.decrypt_with_auth_key(encrypted2, @valid_password, salt2)

assert decrypted_rsa_pem1 == decrypted_rsa_pem2
end
end

0 comments on commit 36aa14a

Please sign in to comment.