Skip to content

Commit

Permalink
Add generate_key and pem_encode wrappers (#13)
Browse files Browse the repository at this point in the history
* Add generate_key and pem_encode wrappers

* Bump to OTP 20

* Bump to OTP 20.1

* Bump Elixir to 1.4.5 (minimal for otp 20)

* Fix typo in release

* Added module docs

* Let travis build OTP 18 and 20

* Fallback to system.cmd when Erlang has no rsa keygen support

* Push logic up to function def

* Do not break ec happy path

* Drop down minimal Elxir version to 1.3.0

* Revert "Drop down minimal Elxir version to 1.3.0"

This reverts commit 10516e7.
  • Loading branch information
barttenbrinke authored and ntrepid8 committed Oct 27, 2017
1 parent 37982d2 commit dbf0587
Show file tree
Hide file tree
Showing 4 changed files with 149 additions and 8 deletions.
3 changes: 2 additions & 1 deletion .travis.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
language: elixir
elixir:
- 1.3.0
- 1.4.5
otp_release:
- 18.2.1
- 20.1
script: mix test --trace
127 changes: 122 additions & 5 deletions lib/ex_public_key.ex
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
defmodule ExPublicKey do
@moduledoc """
API module for public-key infrastructure.
## Description
Mostly wrappers Erlang' `:public_key` module, to help simplify using public/private key encryption in Elixir.
"""

defmacro __using__(_) do
quote do
Expand All @@ -17,19 +24,37 @@ defmodule ExPublicKey do
end
end


@doc """
Loads PEM string from the specified file path and returns a `ExPublicKey.RSAPrivateKey` or a `ExPublicKey.RSAPublicKey` key.
Optionally, a passphrase can be given to decode the PEM certificate.
## Examples
{:ok, key} = ExPublicKey.load("/file/to/cert.pem")
{:ok, key} = ExPublicKey.load("/file/to/cert.pem", "pem_password")
"""
def load(file_path, passphrase \\ nil) do
case File.read(file_path) do
{:ok, key_string} ->
if passphrase do
ExPublicKey.loads(key_string, passphrase)
else
ExPublicKey.loads(key_string)
end
ExPublicKey.loads(key_string, passphrase)
{:error, reason} ->
{:error, reason}
end
end

@doc """
Loads PEM string from the specified file path and returns a `ExPublicKey.RSAPrivateKey` or a `ExPublicKey.RSAPublicKey` key.
Optionally, a passphrase can be given to decode the PEM certificate.
Identical to `ExPublicKey.load/2`, except that load! raises an ExCrypto.Error when an exception occurs.
## Examples
key = ExPublicKey.load("/file/to/cert.pem")
key = ExPublicKey.load("/file/to/cert.pem", "pem_password")
"""
def load!(file_path, passphrase \\ nil) do
case load(file_path, passphrase) do
{:ok, key} ->
Expand All @@ -47,13 +72,31 @@ defmodule ExPublicKey do
end
end

@doc """
Converts a PEM string into an `ExPublicKey.RSAPrivateKey` or an `ExPublicKey.RSAPublicKey` key.
Optionally, a passphrase can be given to decode the PEM certificate.
## Examples
{:ok, key} = ExPublicKey.loads(pem_string)
{:ok, key} = ExPublicKey.loads(pem_string, "pem_password")
"""
def loads(pem_string, passphrase \\ nil) do
pem_entries = :public_key.pem_decode(pem_string)
with {:ok, pem_entry} <- validate_pem_length(pem_entries),
{:ok, rsa_key} <- load_pem_entry(pem_entry, passphrase),
do: sort_key_tup(rsa_key)
end

@doc """
Converts a PEM string into an `ExPublicKey.RSAPrivateKey` or an `ExPublicKey.RSAPublicKey` key.
Identical to `ExPublicKey.loads/2`, except that loads! raises an ExCrypto.Error when an exception occurs.
## Example
key = ExPublicKey.loads!(pem_string)
"""
def loads!(pem_string, passphrase \\ nil) do
case loads(pem_string, passphrase) do
{:ok, key} ->
Expand Down Expand Up @@ -193,7 +236,69 @@ defmodule ExPublicKey do
do: decrypt_public_1([cipher_bytes, rsa_pub_key_seq])
end

def generate_key, do: generate_key(:rsa, 2048, 65537)
def generate_key(bits), do: generate_key(:rsa, bits, 65537)
def generate_key(bits, public_exp), do: generate_key(:rsa, bits, public_exp)
def generate_key(bits, public_exp), do: generate_key(:rsa, bits, public_exp)
def generate_key(:rsa, bits, public_exp), do: generate_key(:rsa, bits, public_exp, otp_has_rsa_gen_support())
def generate_key(:rsa, bits, public_exp, false), do: generate_rsa_openssl_fallback(bits) # Fallback support for OTP 18 & 19.
def generate_key(:rsa, bits, public_exp, true), do: {:ok, :public_key.generate_key({:rsa, bits, public_exp}) |> ExPublicKey.RSAPrivateKey.from_sequence }

@doc """
Generate a new key.
Note: To ensure Backwards compatibility when generating rsa keys on OTP < 20, we fall back to openssl via System.cmd.
## Example
{:ok, rsa_priv_key} = ExPublicKey.generate_key(:rsa, 2048)
"""
def generate_key(type, bits, public_exp) do
{:ok, :public_key.generate_key({type, bits, public_exp}) }
catch
kind, error ->
ExPublicKey.normalize_error(kind, error)
end

@doc """
Extract the public part of a private string and return the results as a ExPublicKey.RSAPublicKey struct.
## Example
{:ok, rsa_pub_key} = ExPublicKey.public_key_from_private_key(rsa_priv_key)
"""
def public_key_from_private_key(private_key = %ExPublicKey.RSAPrivateKey{}) do
{:ok, ExPublicKey.RSAPublicKey.from_sequence({:RSAPublicKey, private_key.public_modulus, private_key.public_exponent})}
end

@doc """
Encode a key into a PEM string.
To decode, use `ExPublicKey.loads/1`
## Example
{:ok, pem_string} = ExPublicKey.pem_encode(key)
"""
def pem_encode(key = %ExPublicKey.RSAPrivateKey{}) do
with {:ok, key_sequence} <- ExPublicKey.RSAPrivateKey.as_sequence(key),
do: pem_entry_encode(key_sequence, :RSAPrivateKey)
end

def pem_encode(key = %ExPublicKey.RSAPublicKey{}) do
with {:ok, key_sequence} <- ExPublicKey.RSAPublicKey.as_sequence(key),
do: pem_entry_encode(key_sequence, :RSAPublicKey)
end

# Helpers
defp pem_entry_encode(key, type) do
pem_entry = :public_key.pem_entry_encode(type, key)
{:ok, :public_key.pem_encode([pem_entry])}
catch
kind, error ->
ExPublicKey.normalize_error(kind, error)
end

defp decode(encoded_payload, _url_safe = true) do
Base.url_decode64(encoded_payload)
end
Expand All @@ -208,4 +313,16 @@ defmodule ExPublicKey do
Base.encode64(payload_bytes)
end

defp otp_has_rsa_gen_support do
case (to_string(Application.spec(:public_key, :vsn)) |> Float.parse) do
:error -> false
{version, _remainder} -> version >= 1.5
end
end

defp generate_rsa_openssl_fallback(bits) do
{pem_entry, _exit_code} = System.cmd("openssl", ["genrsa", to_string(bits)])
loads(pem_entry)
end

end
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule ExCrypto.Mixfile do
[app: :ex_crypto,
version: "0.6.0",
name: "ExCrypto",
elixir: ">= 1.3.0",
elixir: ">= 1.4.5",
description: description(),
package: package(),
deps: deps(),
Expand Down
25 changes: 24 additions & 1 deletion test/ex_public_key_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,23 @@ defmodule ExPublicKeyTest do
assert(rsa_pub_key.__struct__ == RSAPublicKey)
end

test "converts RSA keys to PEM format and back", context do
{:ok, rsa_priv_key} = ExPublicKey.generate_key
{:ok, priv_key_string} = ExPublicKey.pem_encode(rsa_priv_key)

rsa_priv_key_decoded = ExPublicKey.loads!(priv_key_string)
assert(is_map(rsa_priv_key_decoded))
assert(rsa_priv_key_decoded == rsa_priv_key)
end

test "read secure RSA keys", context do
{:ok, secure_priv_key_string} = File.read(context[:rsa_secure_private_key_path])
secure_rsa_priv_key = ExPublicKey.loads!(secure_priv_key_string, context[:passphrase])
assert(is_map(secure_rsa_priv_key))
assert(secure_rsa_priv_key.__struct__ == RSAPrivateKey)
end


test "try random string in key loads function and observe ExCrypto.Error" do
assert_raise ExCrypto.Error, fn ->
ExPublicKey.loads!(ExCrypto.rand_chars(1000))
Expand Down Expand Up @@ -113,7 +123,20 @@ defmodule ExPublicKeyTest do
{:ok, rsa_pub_key} = ExPublicKey.load(context[:rsa_public_key_path])
rand_chars = ExCrypto.rand_chars(16)
plain_text = "This is a test message to encrypt, complete with some entropy (#{rand_chars})."


{:ok, cipher_text} = ExPublicKey.encrypt_private(plain_text, rsa_priv_key)
assert(cipher_text != plain_text)

{:ok, decrypted_plain_text} = ExPublicKey.decrypt_public(cipher_text, rsa_pub_key)
assert(decrypted_plain_text == plain_text)
end

test "RSA private_key encrypt and RSA public_key decrypt using generated keys", context do
{:ok, rsa_priv_key} = ExPublicKey.generate_key
{:ok, rsa_pub_key} = ExPublicKey.public_key_from_private_key(rsa_priv_key)
rand_chars = ExCrypto.rand_chars(16)
plain_text = "This is a test message to encrypt, complete with some entropy (#{rand_chars})."

{:ok, cipher_text} = ExPublicKey.encrypt_private(plain_text, rsa_priv_key)
assert(cipher_text != plain_text)

Expand Down

0 comments on commit dbf0587

Please sign in to comment.