diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ca6d1..d00add9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changes +## v0.9.0 + +* [X509.PrivateKey] Generate an EC private key from pseudo-random (e.g. KDF) + data (`new_ec/2`) +* [X509.PublicKey] Calculate the public key from a raw EC private key; add EC + point multiplication (`mul/2,3`) + ## v0.8.1 ### Fixes diff --git a/README.md b/README.md index 6991023..efc3815 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ Add `x509` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:x509, "~> 0.8"} + {:x509, "~> 0.9"} ] end ``` diff --git a/lib/x509/private_key.ex b/lib/x509/private_key.ex index d375eee..bbfa24c 100644 --- a/lib/x509/private_key.ex +++ b/lib/x509/private_key.ex @@ -4,18 +4,29 @@ defmodule X509.PrivateKey do @moduledoc """ Functions for generating, reading and writing RSA and EC private keys. + Note that this module uses Erlang/OTP's `:public_key` application, which + does not support all curve names returned by the `:crypto.ec_curves/0` + function. In particular, the NIST Prime curves must be selected by their + SECG id, e.g. NIST P-256 is `:secp256r1` rather than `:prime256v1`. Please + refer to [RFC4492 appendix A](https://tools.ietf.org/search/rfc4492#appendix-A) + for a mapping table. + ## Example use with `:public_key` - Encryption and decryption: + ### Encryption and decryption - iex> private_key = X509.PrivateKey.new_rsa(2048) + iex> private_key = X509.PrivateKey.new_rsa(4096) iex> public_key = X509.PublicKey.derive(private_key) iex> plaintext = "Hello, world!" iex> ciphertext = :public_key.encrypt_public(plaintext, public_key) iex> :public_key.decrypt_private(ciphertext, private_key) "Hello, world!" - Signing and signature verification: + Note that in practice it is not a good idea to directly encrypt a message + with asymmetrical cryptography. The examples above are deliberate + over-simpliciations intended to highlight the `:public_key` API calls. + + ### Signing and signature verification iex> private_key = X509.PrivateKey.new_ec(:secp256r1) iex> public_key = X509.PublicKey.derive(private_key) @@ -24,9 +35,20 @@ defmodule X509.PrivateKey do iex> :public_key.verify(message, :sha256, signature, public_key) true - Note that in practice it is not a good idea to directly encrypt a message - with asymmetrical cryptography. The examples above are deliberate - over-simpliciations intended to highlight the `:public_key` API calls. + ### Key exchange + + iex> private_key1 = X509.PrivateKey.new_ec(:secp256r1) + iex> {public_key1, _} = X509.PublicKey.derive(private_key1) + iex> private_key2 = X509.PrivateKey.new_ec(:secp256r1) + iex> {public_key2, _} = X509.PublicKey.derive(private_key2) + iex> shared_secret1 = :public_key.compute_key(public_key2, private_key1) + iex> shared_secret2 = :public_key.compute_key(public_key1, private_key2) + iex> shared_secret1 == shared_secret2 + true + + Since `:public_key.compute_key/2.3` takes an EC point as its first parameter, + we extract the point from the return value of `X509.PublicKey.derive/1` using + pattern matching. """ @typedoc "RSA or EC private key" @@ -54,21 +76,85 @@ defmodule X509.PrivateKey do Generates a new EC private key. To derive the public key, use `X509.PublicKey.derive/1`. - The first parameter must specify a named curve. The curve can be specified - as an atom or an OID tuple. - - Note that this function uses Erlang/OTP's `:public_key` application, which - does not support all curve names returned by the `:crypto.ec_curves/0` - function. In particular, the NIST Prime curves must be selected by their - SECG id, e.g. NIST P-256 is `:secp256r1` rather than `:prime256v1`. Please - refer to [RFC4492 appendix A](https://tools.ietf.org/search/rfc4492#appendix-A) - for a mapping table. + The curve can be specified as an atom or an OID tuple. """ @spec new_ec(:crypto.ec_named_curve() | :public_key.oid()) :: :public_key.ec_private_key() def new_ec(curve) when is_atom(curve) or is_tuple(curve) do :public_key.generate_key({:namedCurve, curve}) end + @doc """ + Deterministically generates an EC private key from a (pseudo)random seed. To + derive the public key, use `X509.PublicKey.derive/1`. + + The first parameter must specify a named curve. The curve can be specified + as an atom or an OID tuple. + + The second parameter is the seed value, which is typically the output of a + secure KDF: + + * If the selected curve is defined over a prime field or characteristic 2 + field the procedure in NIST FIPS-186-4 B.4.1 "Key Pair Generation Using + Extra Random Bits" is used. The `returned_bits` argument must be a binary + that is at least 64 bits (8 bytes) longer than the length of the order of + the curve. + + * For the `:x22519` and `:x448` curves, the `returned_bits` argument must + match the bit-size of the curve (i.e. 256 or 448 bits). The value is + clamped according to the curve requirements and wrapped into an EC + private key record. + """ + @spec new_ec(:crypto.ec_named_curve() | :public_key.oid(), binary()) :: + :public_key.ec_private_key() + def new_ec(curve, returned_bits) when is_tuple(curve) do + # FIXME: avoid calls to undocumented functions in :public_key app + new_ec(:pubkey_cert_records.namedCurves(curve), returned_bits) + end + + def new_ec(:x25519 = curve, <>) do + import Bitwise + + clamped = + returned_bits + |> band(~~~7) + |> band(~~~(128 <<< (8 * 31))) + |> bor(64 <<< (8 * 31)) + + priv = <> + pub = :crypto.compute_key(:ecdh, <<9::integer-little-size(256)>>, priv, curve) + ec_private_key(version: 1, privateKey: priv, parameters: {:namedCurve, curve}, publicKey: pub) + end + + def new_ec(:x448 = curve, <>) do + import Bitwise + + clamped = + returned_bits + |> band(~~~3) + |> bor(128 <<< (8 * 55)) + + priv = <> + pub = :crypto.compute_key(:ecdh, <<5::integer-little-size(448)>>, priv, curve) + ec_private_key(version: 1, privateKey: priv, parameters: {:namedCurve, curve}, publicKey: pub) + end + + def new_ec(curve, returned_bits) when is_atom(curve) and is_binary(returned_bits) do + {_field, _curve, _g, n, _h} = :crypto.ec_curve(curve) + + # NIST FIPS-186-4 B.4.1 + if byte_size(returned_bits) < byte_size(n) + 8, + do: raise(ArgumentError, "`returned_bits` must be at least #{byte_size(n) + 8} bytes") + + d = + returned_bits + |> :binary.decode_unsigned() + |> rem(:binary.decode_unsigned(n) - 1) + |> Kernel.+(1) + + {pub, priv} = :crypto.generate_key(:ecdh, curve, d) + ec_private_key(version: 1, privateKey: priv, parameters: {:namedCurve, curve}, publicKey: pub) + end + @doc """ Wraps a private key in a PKCS#8 PrivateKeyInfo container. """ diff --git a/lib/x509/public_key.ex b/lib/x509/public_key.ex index 2860951..c2df7ae 100644 --- a/lib/x509/public_key.ex +++ b/lib/x509/public_key.ex @@ -17,17 +17,87 @@ defmodule X509.PublicKey do @public_key_records [:RSAPublicKey, :SubjectPublicKeyInfo] @doc """ - Derives the public key from the given RSA or EC private key. + Extracts or calculates the public key from the given RSA or EC private key. """ @spec derive(X509.PrivateKey.t()) :: t() def derive(rsa_private_key(modulus: m, publicExponent: e)) do rsa_public_key(modulus: m, publicExponent: e) end + # If the public key is not available we have to calculate it ourselves + def derive( + ec_private_key( + privateKey: priv, + parameters: {:namedCurve, curve}, + publicKey: :asn1_NOVALUE + ) + ) do + derive(priv, curve) + end + def derive(ec_private_key(parameters: params, publicKey: pub)) do {ec_point(point: pub), params} end + @doc """ + Extracts or calculates the public key from a raw EC private key. + + The private key may be specified as an integer or a binary. The curve can be + specified as an atom or an OID tuple. + """ + @spec derive(binary(), :crypto.ec_named_curve() | :public_key.oid()) :: t() + def derive(priv, curve) when is_tuple(curve) do + # FIXME: avoid calls to undocumented functions in :public_key app + derive(priv, :pubkey_cert_records.namedCurves(curve)) + end + + def derive(<>, :x25519 = curve) do + pub = :crypto.compute_key(:ecdh, <<9::integer-little-size(256)>>, priv, curve) + {ec_point(point: pub), {:namedCurve, curve}} + end + + def derive(<>, :x448 = curve) do + pub = :crypto.compute_key(:ecdh, <<5::integer-little-size(448)>>, priv, curve) + {ec_point(point: pub), {:namedCurve, curve}} + end + + def derive(priv, curve) when is_binary(priv) and is_atom(curve) do + {pub, _} = :crypto.generate_key(:ecdh, curve, priv) + {ec_point(point: pub), {:namedCurve, curve}} + end + + @doc """ + Performs point multiplication on an elliptic curve. + + The point may be specified as a public key tuple, an ECPoint record or a + binary. These last two require the curve to be specified as an atom or OID. + The multiplier may be specified as an integer or a binary. + + Returns a public key tuple containing the new ECPoint and the curve + parameters. + """ + @spec mul(t(), integer() | binary()) :: t() + def mul({ec_point, {:namedCurve, curve}}, multiplier) do + mul(ec_point, multiplier, curve) + end + + @spec mul(binary(), integer() | binary(), :crypto.ec_named_curve() | :public_key.oid()) :: t() + def mul(point, multiplier, curve) when is_tuple(curve) do + # FIXME: avoid calls to undocumented functions in :public_key app + mul(point, multiplier, :pubkey_cert_records.namedCurves(curve)) + end + + def mul(ec_point(point: point), multiplier, curve) do + mul(point, multiplier, curve) + end + + def mul(point, multiplier, curve) do + # TODO: this doesn't work for x25519 and x448 + {f, c, _g, n, h} = :crypto.ec_curve(curve) + {pub, _} = :crypto.generate_key(:ecdh, {f, c, point, n, h}, multiplier) + {ec_point(point: pub), {:namedCurve, curve}} + end + @doc """ Wraps a public key in a SubjectPublicKeyInfo (or similar) container. diff --git a/mix.exs b/mix.exs index 48bddd7..3610960 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule X509.MixProject do use Mix.Project - @version "0.8.1" + @version "0.9.0" def project do [ diff --git a/mix.lock b/mix.lock index d27f1f1..979cea7 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,9 @@ %{ - "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, - "ex_doc": {:hex, :ex_doc, "0.21.0", "7af8cd3e3df2fe355e99dabd2d4dcecc6e76eb417200e3b7a3da362d52730e3c", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, + "curve25519": {:hex, :curve25519, "1.0.3", "101248eeb2ed28f1b8d2d423cf560bfc99f5e22bae5a2bb113fd24efad05fae1", [:mix], [], "hexpm", "aa936079df2bcefb5cffa68840dc048f222fb7a5cb5827c735dacddba30e3b23"}, + "curve448": {:hex, :curve448, "1.0.3", "d86308f3be3a59548d9e7168979d94be95e42e8a5139785119154e56ff7c4aed", [:mix], [], "hexpm", "009ee5072067743d6074f3903d53f0d7fb83b1411920c3f0032b41582e50c09a"}, + "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm", "e3be2bc3ae67781db529b80aa7e7c49904a988596e2dbff897425b48b3581161"}, + "ex_doc": {:hex, :ex_doc, "0.21.0", "7af8cd3e3df2fe355e99dabd2d4dcecc6e76eb417200e3b7a3da362d52730e3c", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "ef679a81de63385c7e72597e81ca1276187505eeacb38281a672d2822254ff1a"}, + "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm", "5c040b8469c1ff1b10093d3186e2e10dbe483cd73d79ec017993fb3985b8a9b3"}, } diff --git a/test/x509/private_key_test.exs b/test/x509/private_key_test.exs index 7567cfa..f20cd57 100644 --- a/test/x509/private_key_test.exs +++ b/test/x509/private_key_test.exs @@ -66,6 +66,12 @@ defmodule X509.PrivateKeyTest do assert match?(ec_private_key(), new_ec(oid(:secp256r1))) assert_raise(FunctionClauseError, fn -> new_ec(:no_such_curve) end) + + ec1 = new_ec(:secp256r1, "1234567890123456789012345678901234567890") + ec2 = new_ec(oid(:secp256r1), "1234567890123456789012345678901234567890") + assert ec1 == ec2 + + assert_raise(ArgumentError, fn -> new_ec(:secp256r1, "12345678901234567890123456789012") end) end test "wrap and unwrap", context do diff --git a/test/x509/public_key_test.exs b/test/x509/public_key_test.exs index 34d306a..6cab90c 100644 --- a/test/x509/public_key_test.exs +++ b/test/x509/public_key_test.exs @@ -68,6 +68,31 @@ defmodule X509.PublicKeyTest do assert :public_key.verify("message", :sha256, signature, derive(context.ec_key)) end + test "mul" do + # This is actually the P-256 base point; would be better to find test + # vectors with a different base point... + p = + point( + :secp256r1, + "6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296", + "4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5" + ) + + assert X509.PublicKey.mul(p, 10) == + point( + :secp256r1, + "CEF66D6B2A3A993E591214D1EA223FB545CA6C471C48306E4C36069404C5723F", + "878662A229AAAE906E123CDD9D3B4C10590DED29FE751EEECA34BBAA44AF0773" + ) + + assert X509.PublicKey.mul(p, 112_233_445_566_778_899_112_233_445_566_778_899) == + point( + :secp256r1, + "1B7E046A076CC25E6D7FA5003F6729F665CC3241B5ADAB12B498CD32F2803264", + "BFEA79BE2B666B073DB69A2A241ADAB0738FE9D2DD28B5604EB8C8CF097C457B" + ) + end + test "wrap and unwrap", context do assert match?(subject_public_key_info(), wrap(context.ec_pub)) assert context.ec_pub == context.ec_pub |> wrap() |> unwrap() @@ -107,4 +132,9 @@ defmodule X509.PublicKeyTest do assert der == der |> from_der!() |> to_der() end end + + defp point(curve, x, y) do + {{:ECPoint, <<4, Base.decode16!(x)::binary, Base.decode16!(y)::binary>>}, + {:namedCurve, curve}} + end end