diff --git a/CHANGELOG.md b/CHANGELOG.md index 825c32f..ec4d9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,24 @@ # Changelog + +## v0.6.0 + +### Enhancements + +* Introduced `OpenPGP.Encode` protocol with `.encode/1,2` and `.tag/1`. +* Add `OpenPGP.Encode` protocol implementation for: + * `OpenPGP.PublicKeyEncryptedSessionKeyPacket` + * `OpenPGP.IntegrityProtectedDataPacket` + * `OpenPGP.LiteralDataPacket` + * `OpenPGP.Packet` + * `OpenPGP.Packet.PacketTag` + * `OpenPGP.Packet.BodyChunk` +* Introduced `OpenPGP.Encrypt` protocol with `.encrypt/1,2`. +* Add `OpenPGP.Encrypt` protocol implementation for: + * `OpenPGP.PublicKeyEncryptedSessionKeyPacket` with Elgamal (Public-Key algo 16). + * `OpenPGP.IntegrityProtectedDataPacket` with AES-128, AES-192, AES-256 (Sym.algo 7,8,9). +* Added `OpenPGP.encode_packet/1` that delegate to `OpenPGP.Encode` protocol. +* Added `OpenPGP.encrypt_packet/1,2` that delegate to `OpenPGP.Encrypt` protocol. +* Add ElGamal algorithm support to `OpenPGP.PublicKeyPacket.decode/1`. +* Introduced `OpenPGP.ModificationDetectionCodePacket`. +* Introduced `OpenPGP.Util.PKCS1` with PKCS#1 block encoding EME-PKCS1-v1_5. +* Refactored `OpenPGP.Util.encode_mpi/1` and added exception for too long big-endian numbers (>65535 octets). diff --git a/README.md b/README.md index ae1e802..8648821 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,10 @@ Packet specific decoders implement `OpenPGP.Packet.Behaviour`, which exposes `.d Usage example of a comon use case can be found in `test/open_pgp/open_pgp_test.exs` in the test **"full integration: load private key and decrypt encrypted file"** +As of v0.6.x + +1. Limited support of encoding and encryption (see CHANGELOG.md) for details. + ## Refs, Snippets, Misc ```console diff --git a/lib/open_pgp.ex b/lib/open_pgp.ex index 091d87b..02c8341 100644 --- a/lib/open_pgp.ex +++ b/lib/open_pgp.ex @@ -73,18 +73,21 @@ defmodule OpenPGP do ] """ + alias __MODULE__.Encode + alias __MODULE__.Encrypt alias __MODULE__.Packet alias __MODULE__.Packet.PacketTag alias __MODULE__.Util @type any_packet :: - OpenPGP.Packet.t() - | OpenPGP.PublicKeyEncryptedSessionKeyPacket.t() - | OpenPGP.SecretKeyPacket.t() - | OpenPGP.PublicKeyPacket.t() - | OpenPGP.CompressedDataPacket.t() - | OpenPGP.IntegrityProtectedDataPacket.t() - | OpenPGP.LiteralDataPacket.t() + %OpenPGP.Packet{} + | %OpenPGP.PublicKeyEncryptedSessionKeyPacket{} + | %OpenPGP.SecretKeyPacket{} + | %OpenPGP.PublicKeyPacket{} + | %OpenPGP.CompressedDataPacket{} + | %OpenPGP.IntegrityProtectedDataPacket{} + | %OpenPGP.LiteralDataPacket{} + | %OpenPGP.ModificationDetectionCodePacket{} @doc """ Decode all packets in a message (input). @@ -145,4 +148,18 @@ defmodule OpenPGP do packet end end + + @doc "Encode any packet (except for %Packet{}) that implements `OpenPGP.Encode` protocol." + @spec encode_packet(any_packet()) :: binary() + def encode_packet(%{} = packet) do + tag = Encode.tag(packet) + ptag = %PacketTag{format: :new, tag: tag} + body = Encode.encode(packet) + + Encode.encode(%Packet{tag: ptag, body: body}) + end + + @doc "Encrypt any packet that implements `OpenPGP.Encrypt` protocol." + @spec encrypt_packet(packet, opts :: Keyword.t()) :: packet when packet: any_packet() + def encrypt_packet(%{} = packet, opts \\ []) when is_list(opts), do: Encrypt.encrypt(packet, opts) end diff --git a/lib/open_pgp/encode.ex b/lib/open_pgp/encode.ex new file mode 100644 index 0000000..f821f2a --- /dev/null +++ b/lib/open_pgp/encode.ex @@ -0,0 +1,15 @@ +defprotocol OpenPGP.Encode do + @spec tag(t) :: OpenPGP.Packet.PacketTag.tag_tuple() + def tag(packet) + + @spec encode(t, opts :: Keyword.t()) :: binary() + def encode(packet, opts \\ []) +end + +# There is a "bug" in dialyxir on Elixir 1.13/OTP24 and Elixir1.14/OTP25 +# https://github.com/elixir-lang/elixir/issues/7708#issuecomment-403422965 + +defimpl OpenPGP.Encode, for: [Atom, BitString, Float, Function, Integer, List, Map, PID, Port, Reference, Tuple] do + def tag(subj), do: raise(Protocol.UndefinedError, protocol: @protocol, value: subj) + def encode(subj, _), do: raise(Protocol.UndefinedError, protocol: @protocol, value: subj) +end diff --git a/lib/open_pgp/encode/integrity_protected_data_packet_impl.ex b/lib/open_pgp/encode/integrity_protected_data_packet_impl.ex new file mode 100644 index 0000000..1c83d34 --- /dev/null +++ b/lib/open_pgp/encode/integrity_protected_data_packet_impl.ex @@ -0,0 +1,18 @@ +defimpl OpenPGP.Encode, for: OpenPGP.IntegrityProtectedDataPacket do + alias OpenPGP.IntegrityProtectedDataPacket + + def tag(_), do: {18, "Sym. Encrypted and Integrity Protected Data Packet"} + + @doc """ + Encode a Sym. Encrypted and Integrity Protected Data Packet. + Return encoded packet body. + + ### Example: + + iex> packet = %OpenPGP.IntegrityProtectedDataPacket{ciphertext: "Ciphertext"} + ...> OpenPGP.Encode.encode(packet) + <<1::8, "Ciphertext">> + """ + @version 1 + def encode(%IntegrityProtectedDataPacket{ciphertext: ciphertext}, _opts), do: <<@version::8, ciphertext::binary>> +end diff --git a/lib/open_pgp/encode/literal_data_packet_impl.ex b/lib/open_pgp/encode/literal_data_packet_impl.ex new file mode 100644 index 0000000..8a68f0f --- /dev/null +++ b/lib/open_pgp/encode/literal_data_packet_impl.ex @@ -0,0 +1,53 @@ +defimpl OpenPGP.Encode, for: OpenPGP.LiteralDataPacket do + alias OpenPGP.LiteralDataPacket + + def tag(_), do: {11, "Literal Data Packet"} + + @format_map %{ + binary: <<0x62::8>>, + text: <<0x74::8>>, + text_utf8: <<0x75::8>> + } + @formats Map.keys(@format_map) + + @doc """ + Encode Literal Data Packet. + Return encoded packet body. + + ### Example: + + iex> packet = %OpenPGP.LiteralDataPacket{ + ...> format: :binary, + ...> file_name: "file.txt", + ...> created_at: ~U[2022-02-24 02:30:00Z], + ...> data: "Hello" + ...> } + ...> OpenPGP.Encode.encode(packet) + <<0x62, 8, "file.txt", 1645669800::32, "Hello">> + + """ + def encode(%LiteralDataPacket{data: "" <> _ = data} = packet, _opts) do + format = packet.format || :binary + + format_byte = + Map.get(@format_map, format) || + raise """ + Unknown Literal Data Packet format: #{inspect(packet.format)}. + Known formats: #{inspect(@formats)} + """ + + fname_string = + case packet.file_name do + fname when is_binary(fname) and byte_size(fname) > 0 -> <> + nil -> <<0::8>> + end + + timestamp = + case packet.created_at do + %DateTime{} = date -> DateTime.to_unix(date) + nil -> System.os_time(:second) + end + + <> + end +end diff --git a/lib/open_pgp/encode/packet/body_chunk_impl.ex b/lib/open_pgp/encode/packet/body_chunk_impl.ex new file mode 100644 index 0000000..2d3f6ec --- /dev/null +++ b/lib/open_pgp/encode/packet/body_chunk_impl.ex @@ -0,0 +1,56 @@ +defimpl OpenPGP.Encode, for: OpenPGP.Packet.BodyChunk do + alias OpenPGP.Packet.BodyChunk + + def tag(_), do: raise(".tag/1 of protocol #{inspect(@protocol)} not supported by design for #{inspect(@for)}.") + + @doc """ + Encode body chunk. Always uses new packet format. + Return encoded body chunk with the length header prefix. + + ### Example: + + Encodes one-octet length New Format Packet Length Header (up to 191 octets) + + iex> OpenPGP.Encode.encode(%OpenPGP.Packet.BodyChunk{data: "Hello world!"}) + <<12::8, "Hello world!">> + + Encodes two-octet length New Format Packet Length Header (192-8383 octets) + + iex> rand_bytes = :crypto.strong_rand_bytes(255) + ...> OpenPGP.Encode.encode(%OpenPGP.Packet.BodyChunk{data: rand_bytes}) + <<192::8, 63::8, rand_bytes::binary>> + + Encodes five-octet length New Format Packet Length Header (8384-4_294_967_295 (0xFFFFFFFF) octets) + + iex> rand_bytes = :crypto.strong_rand_bytes(8384) + ...> OpenPGP.Encode.encode(%OpenPGP.Packet.BodyChunk{data: rand_bytes}) + <<255::8, 8384::32, rand_bytes::binary>> + """ + @one_octet_length 0..191 + @two_octet_length 192..8383 + @five_octet_length 8384..0xFFFFFFFF + def encode(%BodyChunk{data: "" <> _ = data}, _opts) do + blen = byte_size(data) + + hlen = + cond do + blen in @one_octet_length -> + <> + + blen in @two_octet_length -> + <> = <> + <> + + blen in @five_octet_length -> + <<255::8, blen::32>> + + true -> + raise """ + Encoding of body chunks with length greater than 0xFFFFFFFF octets is not implemented. + Consider implementing a Partial Body Length Header. + """ + end + + hlen <> data + end +end diff --git a/lib/open_pgp/encode/packet/packet_tag_impl.ex b/lib/open_pgp/encode/packet/packet_tag_impl.ex new file mode 100644 index 0000000..35fd71d --- /dev/null +++ b/lib/open_pgp/encode/packet/packet_tag_impl.ex @@ -0,0 +1,23 @@ +defimpl OpenPGP.Encode, for: OpenPGP.Packet.PacketTag do + alias OpenPGP.Packet.PacketTag + + def tag(_), do: raise(".tag/1 of protocol #{inspect(@protocol)} not supported by design for #{inspect(@for)}.") + + @doc """ + Encode packet tag. Always uses new packet format. + Return encoded packet tag octet. + + ### Example: + + iex> ptag = %OpenPGP.Packet.PacketTag{format: :new, tag: {1, "Public-Key Encrypted Session Key Packet"}} + ...> OpenPGP.Encode.encode(ptag) + <<1::1, 1::1, 1::6>> + + iex> ptag = %OpenPGP.Packet.PacketTag{format: :new, tag: {2, "Signature Packet"}} + ...> OpenPGP.Encode.encode(ptag) + <<1::1, 1::1, 2::6>> + """ + def encode(%PacketTag{format: :new, tag: {tag_id, _desc}}, _opts) when is_integer(tag_id) and tag_id in 0..63 do + <<1::1, 1::1, tag_id::6>> + end +end diff --git a/lib/open_pgp/encode/packet_impl.ex b/lib/open_pgp/encode/packet_impl.ex new file mode 100644 index 0000000..fee404e --- /dev/null +++ b/lib/open_pgp/encode/packet_impl.ex @@ -0,0 +1,28 @@ +defimpl OpenPGP.Encode, for: OpenPGP.Packet do + alias OpenPGP.Packet + alias OpenPGP.Packet.BodyChunk + + def tag(_), do: raise(".tag/1 of protocol #{inspect(@protocol)} not supported by design for #{inspect(@for)}.") + + @doc """ + Encode a Packet. + Return encoded packet binary - a packet header, followed by the packet body. + + ### Example: + + iex> ptag = %OpenPGP.Packet.PacketTag{format: :new, tag: {11, "Literal Data Packet"}} + ...> OpenPGP.Encode.encode(%OpenPGP.Packet{tag: ptag, body: "Hello, World!!!"}) + <<1::1, 1::1, 11::6, 15::8, "Hello, World!!!">> + """ + def encode(%Packet{} = packet, _opts) do + encoded_body = + case packet.body do + nil -> <<0::8>> + [] -> <<0::8>> + "" <> _ = body -> @protocol.encode(%BodyChunk{data: body}, []) + [%BodyChunk{} | _] = chunks -> Enum.reduce(chunks, "", fn chunk, acc -> acc <> @protocol.encode(chunk) end) + end + + @protocol.encode(packet.tag) <> encoded_body + end +end diff --git a/lib/open_pgp/encode/public_key_encrypted_session_key_packet_impl.ex b/lib/open_pgp/encode/public_key_encrypted_session_key_packet_impl.ex new file mode 100644 index 0000000..ee55527 --- /dev/null +++ b/lib/open_pgp/encode/public_key_encrypted_session_key_packet_impl.ex @@ -0,0 +1,25 @@ +defimpl OpenPGP.Encode, for: OpenPGP.PublicKeyEncryptedSessionKeyPacket do + alias OpenPGP.PublicKeyEncryptedSessionKeyPacket, as: PKESK + + def tag(_), do: {1, "Public-Key Encrypted Session Key Packet"} + + @doc """ + Encode Public-Key Encrypted Session Key Packet. + Return encoded packet body. + + ### Example + + iex> packet = %OpenPGP.PublicKeyEncryptedSessionKeyPacket{ + ...> ciphertext: "Ciphertext", + ...> public_key_id: "6BAF2C48", + ...> public_key_algo: {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} + ...> } + ...> OpenPGP.Encode.encode(packet) + <<3::8, "6BAF2C48", 16::8, "Ciphertext">> + """ + @version 3 + def encode(%PKESK{ciphertext: ciphertext, public_key_id: key_id, public_key_algo: {key_algo_id, _}}, _opts) + when is_binary(ciphertext) and is_binary(key_id) and key_algo_id in 1..255 do + <<@version::8, key_id::binary, key_algo_id::8, ciphertext::binary>> + end +end diff --git a/lib/open_pgp/encrypt.ex b/lib/open_pgp/encrypt.ex new file mode 100644 index 0000000..9628c95 --- /dev/null +++ b/lib/open_pgp/encrypt.ex @@ -0,0 +1,11 @@ +defprotocol OpenPGP.Encrypt do + @spec encrypt(t(), opts :: Keyword.t()) :: t() + def encrypt(packet, opts \\ []) +end + +# There is a "bug" in dialyxir on Elixir 1.13/OTP24 and Elixir1.14/OTP25 +# https://github.com/elixir-lang/elixir/issues/7708#issuecomment-403422965 + +defimpl OpenPGP.Encrypt, for: [Atom, BitString, Float, Function, Integer, List, Map, PID, Port, Reference, Tuple] do + def encrypt(subj, _), do: raise(Protocol.UndefinedError, protocol: @protocol, value: subj) +end diff --git a/lib/open_pgp/encrypt/integrity_protected_data_packet_impl.ex b/lib/open_pgp/encrypt/integrity_protected_data_packet_impl.ex new file mode 100644 index 0000000..8868adf --- /dev/null +++ b/lib/open_pgp/encrypt/integrity_protected_data_packet_impl.ex @@ -0,0 +1,49 @@ +defimpl OpenPGP.Encrypt, for: OpenPGP.IntegrityProtectedDataPacket do + alias OpenPGP.ModificationDetectionCodePacket, as: MDC + alias OpenPGP.IntegrityProtectedDataPacket, as: DataPacket + alias OpenPGP.Util + + @doc """ + Encrypt Sym. Encrypted Integrity Protected Data Packet with a given sym.algo and session key. + + Require `OpenPGP.IntegrityProtectedDataPacket` fields: + + - `:plaintext` - a binary + + Accept options keyword list as a secont argument: + + - `:session_key` - a key for sym.algo (required) + - `:session_key_algo` - a tuple representing sym.algo (required, see `t:Util.sym_algo_tuple()`) + - `:use_mdc` - the Modification Detection Code Packet added if set to `true` (optional, default `true`) + + Return updated `OpenPGP.IntegrityProtectedDataPacket` with populated fields: + + - `:ciphertext` + """ + def encrypt(%DataPacket{} = packet, opts) do + session_key = + Keyword.get(opts, :session_key) || + raise """ + Missing options key :session_key - a key for sym.algo + """ + + session_key_algo = + Keyword.get(opts, :session_key_algo) || + raise """ + Missing options key :session_key_algo - a tuple representing sym.algo (see `t:Util.sym_algo_tuple()`) + """ + + crypto_cipher = Util.sym_algo_to_crypto_cipher(session_key_algo) + null_iv = DataPacket.build_null_iv(session_key_algo) + checksum = DataPacket.build_checksum(session_key_algo) + + data = + if Keyword.get(opts, :use_mdc, true), + do: MDC.append_to(checksum <> packet.plaintext), + else: checksum <> packet.plaintext + + ciphertext = :crypto.crypto_one_time(crypto_cipher, session_key, null_iv, data, true) + + %{packet | ciphertext: ciphertext} + end +end diff --git a/lib/open_pgp/encrypt/public_key_encrypted_session_key_packet_impl.ex b/lib/open_pgp/encrypt/public_key_encrypted_session_key_packet_impl.ex new file mode 100644 index 0000000..5c84c7c --- /dev/null +++ b/lib/open_pgp/encrypt/public_key_encrypted_session_key_packet_impl.ex @@ -0,0 +1,85 @@ +defimpl OpenPGP.Encrypt, for: OpenPGP.PublicKeyEncryptedSessionKeyPacket do + alias OpenPGP.PublicKeyPacket + alias OpenPGP.PublicKeyEncryptedSessionKeyPacket, as: PKESK + alias OpenPGP.Util + + @doc """ + Encrypt Public-Key Encrypted Session Key Packet with a given recipient's public key. + + Require `OpenPGP.PublicKeyEncryptedSessionKeyPacket` fields: + + - `:session_key_algo` - a valid sym.algo tuple (see `t:OpenPGP.Util.sym_algo_tuple()`) + - `:session_key_material` - a valid sym.algo key material (typically a one-element tuple) + + Accept options keyword list as a secont argument: + + - `:recipient_public_key` - an `%OpenPGP.PublicKeyPacket{}` with non-empty `:algo` and `:material` fields (required) + + Return updated `%OpenPGP.PublicKeyEncryptedSessionKeyPacket{}` with populated fields: + + - `:public_key_id` + - `:public_key_algo` + - `:ciphertext` + """ + def encrypt(%PKESK{} = packet, opts) do + {session_key} = + packet.session_key_material || + raise """ + Expected :session_key_material field to have a valid session_key_material (i.e. `{<<...>>}`). Got: #{inspect(packet.session_key_material)}. + """ + + session_key_algo = + packet.session_key_algo || + raise """ + Expected :session_key_algo field to have a valid session_key_algo (i.e. `{9, "AES with 256-bit key"}`). Got: #{inspect(packet.session_key_algo)}. + """ + + %PublicKeyPacket{algo: public_key_algo, material: public_key_material, id: public_key_id} = + Keyword.get(opts, :recipient_public_key) || + raise """ + Missing options key :recipient_public_key - a `%PublicKey{}` struct. + """ + + material = build_material(session_key, session_key_algo, public_key_material, public_key_algo) + + ciphertext = + for el <- Tuple.to_list(material), reduce: "" do + acc -> acc <> Util.encode_mpi(el) + end + + %{packet | public_key_id: public_key_id, public_key_algo: public_key_algo, ciphertext: ciphertext} + end + + defp build_material("" <> _ = session_key, session_key_algo, public_key_material, {16, _} = _public_key_algo) do + {prime_p, group_g, value_y} = public_key_material + {sym_algo_id, _} = session_key_algo + + {sender_pub_key, _private} = :crypto.generate_key(:dh, [prime_p, group_g]) + + k = :binary.decode_unsigned(sender_pub_key) + p = :binary.decode_unsigned(prime_p) + g = :binary.decode_unsigned(group_g) + y = :binary.decode_unsigned(value_y) + + checksum = + for <>, reduce: 0 do + acc -> rem(acc + byte, 65536) + end + + value_m = Util.PKCS1.encode(:eme_pkcs1_v1_5, <>, sender_pub_key) + m = :binary.decode_unsigned(value_m) + + g_k_mod_p = :crypto.mod_pow(g, k, p) + y_k_mod_p = :crypto.mod_pow(y, k, p) + m_y_k_mod_p = (m * :binary.decode_unsigned(y_k_mod_p)) |> rem(p) |> :binary.encode_unsigned() + + {g_k_mod_p, m_y_k_mod_p} + end + + @v06x_note """ + As of 0.6.x the Public Key Encrypted Session Key Packet encrypts the session key only with "Elgamal (Encrypt-Only)" (algo 16) + """ + defp build_material(_session_key, _session_key_algo, _public_key_material, public_key_algo) do + raise("Unsupported PKESK encription algo #{inspect(public_key_algo)}. " <> @v06x_note) + end +end diff --git a/lib/open_pgp/integrity_protected_data_packet.ex b/lib/open_pgp/integrity_protected_data_packet.ex index 6418ba6..350f1e8 100644 --- a/lib/open_pgp/integrity_protected_data_packet.ex +++ b/lib/open_pgp/integrity_protected_data_packet.ex @@ -1,9 +1,11 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do - @v05x_note """ - As of 0.5.x Symmetrically Encrypted Integrity Protected Data Packet: + @v06x_note """ + As of 0.6.x Symmetrically Encrypted Integrity Protected Data Packet: + 1. Modification Detection Code system is supported, but not enforced in decryption + 1. Supports Session Key algo 7 (AES with 128-bit key) in CFB mode + 1. Supports Session Key algo 8 (AES with 192-bit key) in CFB mode 1. Supports Session Key algo 9 (AES with 256-bit key) in CFB mode - 2. Modification Detection Code system is not supported """ @moduledoc """ Represents structured data for Integrity Protected Data Packet. @@ -54,7 +56,7 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do } - > NOTE: #{@v05x_note} + > NOTE: #{@v06x_note} --- @@ -113,6 +115,7 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do @behaviour OpenPGP.Packet.Behaviour + alias OpenPGP.ModificationDetectionCodePacket, as: MDC alias OpenPGP.PublicKeyEncryptedSessionKeyPacket, as: PKESK alias OpenPGP.Util @@ -146,54 +149,98 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do Encrypted Session Key Packet. Return PKESK-Packet with `:plaintext` attr assigned. Raises an error if checksum does not match. + Accepts options keyword list as a third argument (optional): + + - `:use_mdc` - validates Modification Detection Code Packet and raises on failure if set to `true` (default: `false`) """ - @spec decrypt(t(), PKESK.t()) :: t() - def decrypt(%__MODULE__{ciphertext: ciphertext} = packet, %PKESK{} = pkesk) do - session_key = - case pkesk do - %PKESK{session_key_algo: {9, _}, session_key_material: {session_key}} -> session_key - _ -> raise(@v05x_note <> "\n Got: #{inspect(pkesk)}") + @spec decrypt(t(), PKESK.t(), opts :: [{:use_mdc, boolean()}]) :: t() + def decrypt(%__MODULE__{} = packet, %PKESK{} = pkesk, opts \\ []) do + sym_key_algo = pkesk.session_key_algo + crypto_cipher = Util.sym_algo_to_crypto_cipher(sym_key_algo) + sym_key = elem(pkesk.session_key_material, 0) + null_iv = build_null_iv(sym_key_algo) + ciphertext = packet.ciphertext + + payload = :crypto.crypto_one_time(crypto_cipher, sym_key, null_iv, ciphertext, false) + + {data, _chsum} = validate_checksum!(payload, sym_key_algo) + + plaintext = + if Keyword.get(opts, :use_mdc, false) do + {data_w_checksum, _sha} = MDC.validate!(payload) + {data, _, _, _} = trim_checksum(data_w_checksum, sym_key_algo) + + data + else + data end - null_iv = build_null_iv(pkesk) - payload = decrypt(:aes_256_cfb128, session_key, null_iv, ciphertext) - {data, _chsum} = validate_checksum!(payload, pkesk) - - %{packet | plaintext: data} + %{packet | plaintext: plaintext} end - @checksum_octets 2 - defp validate_checksum!("" <> _ = plaintext, %PKESK{session_key_algo: sk_algo}) do - cipher_block_octets = sk_algo |> Util.sym_algo_cipher_block_size() |> Kernel.div(8) - prefix_byte_size = cipher_block_octets - @checksum_octets - - << - _::bytes-size(prefix_byte_size), - chsum1::bytes-size(@checksum_octets), - chsum2::bytes-size(@checksum_octets), - data::binary - >> = plaintext + @checksum_size 2 * 8 + defp validate_checksum!("" <> _ = plaintext, algo) do + {data, chsum1, chsum2, prefix} = trim_checksum(plaintext, algo) if chsum1 == chsum2 do {data, chsum1} else + prefix_byte_size = byte_size(prefix) chsum1_hex = inspect(chsum1, base: :binary) chsum2_hex = inspect(chsum2, base: :binary) msg = "Expected IntegrityProtectedDataPacket prefix octets " <> - "#{prefix_byte_size + 1}, #{prefix_byte_size + 2} to match octets " <> - "#{prefix_byte_size + 3}, #{prefix_byte_size + 4}: #{chsum1_hex} != #{chsum2_hex}." + "#{prefix_byte_size - 3}, #{prefix_byte_size - 2} to match octets " <> + "#{prefix_byte_size - 1}, #{prefix_byte_size - 0}: #{chsum1_hex} != #{chsum2_hex}." raise(msg) end end - defp build_null_iv(%PKESK{session_key_algo: sk_algo}) do - size_bits = Util.sym_algo_cipher_block_size(sk_algo) - for(_ <- 1..size_bits, into: <<>>, do: <<0::1>>) + defp trim_checksum("" <> _ = plaintext, algo) do + cipher_block_size = Util.sym_algo_cipher_block_size(algo) + prefix_size = cipher_block_size - @checksum_size + <> = plaintext + + {data, chsum1, chsum2, <>} + end + + @doc """ + Build a checksum prefix. + + > Instead of using an IV, OpenPGP prefixes an octet string to the data + > before it is encrypted. The length of the octet string equals the + > block size of the cipher in octets, plus two. The first octets in + > the group, of length equal to the block size of the cipher, are + > random; the last two octets are each copies of their 2nd preceding + > octet. For example, with a cipher whose block size is 128 bits or + > 16 octets, the prefix data will contain 16 random octets, then two + > more octets, which are copies of the 15th and 16th octets, + > respectively. + """ + @checksum_size 2 * 8 + @spec build_checksum(Util.sym_algo_tuple()) :: binary() + def build_checksum(algo) do + cipher_block_size = Util.sym_algo_cipher_block_size(algo) + prefix_size = cipher_block_size - @checksum_size + random_bytes = :crypto.strong_rand_bytes(div(cipher_block_size, 8)) + + <<_::size(prefix_size), chsum::size(@checksum_size)>> = random_bytes + + <> end - defp decrypt(cipher, key, iv, ciphertext), - do: :crypto.crypto_one_time(cipher, key, iv, ciphertext, false) + @doc """ + Build the Initial Vector (IV) as all zeroes. + + > The Initial Vector (IV) is specified as all zeros. Instead of using + > an IV, OpenPGP prefixes an octet string to the data before it is + > encrypted. + """ + @spec build_null_iv(Util.sym_algo_tuple() | byte()) :: binary() + def build_null_iv(algo) do + size_bits = Util.sym_algo_cipher_block_size(algo) + for(_ <- 1..size_bits, into: <<>>, do: <<0::1>>) + end end diff --git a/lib/open_pgp/modification_detection_code_packet.ex b/lib/open_pgp/modification_detection_code_packet.ex new file mode 100644 index 0000000..b7a46c4 --- /dev/null +++ b/lib/open_pgp/modification_detection_code_packet.ex @@ -0,0 +1,107 @@ +defmodule OpenPGP.ModificationDetectionCodePacket do + @moduledoc """ + Represents structured data for Modification Detection Code Packet. + + ### Example: + + iex> alias OpenPGP.ModificationDetectionCodePacket + ...> data = :crypto.hash(:sha, <<"Hello!", 0xD3, 0x14>>) + ...> ModificationDetectionCodePacket.decode(data) + { + %ModificationDetectionCodePacket{ + sha: <<24, 124, 192, 238, 22, 94, 219, 146, 73, 3, 220, 145, 130, 2, 184, 60, 245, 227, 44, 17>> + }, + <<>> + } + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 5.14. Modification Detection Code Packet (Tag 19) + + The Modification Detection Code packet contains a SHA-1 hash of + plaintext data, which is used to detect message modification. It is + only used with a Symmetrically Encrypted Integrity Protected Data + packet. The Modification Detection Code packet MUST be the last + packet in the plaintext data that is encrypted in the Symmetrically + Encrypted Integrity Protected Data packet, and MUST appear in no + other place. + + A Modification Detection Code packet MUST have a length of 20 octets. + + The body of this packet consists of: + + - A 20-octet SHA-1 hash of the preceding plaintext data of the + Symmetrically Encrypted Integrity Protected Data packet, + including prefix data, the tag octet (0xD3), and length octet of + the Modification Detection Code packet (0x14). + + Note that the Modification Detection Code packet MUST always use a + new format encoding of the packet tag, and a one-octet encoding of + the packet length. The reason for this is that the hashing rules for + modification detection include a one-octet tag and one-octet length + in the data hash. While this is a bit restrictive, it reduces + complexity. + """ + + @behaviour OpenPGP.Packet.Behaviour + + defstruct [:sha] + + @type t :: %__MODULE__{ + sha: <<_::160>> + } + + @mdc_header <<0xD3, 0x14>> + @mdc_byte_size 20 + + @doc """ + Decode packet given input binary. + Returns structured packet and remaining binary (empty string). + Expects input binary to be 20 octets long (the length of SHA-1). + """ + @impl OpenPGP.Packet.Behaviour + @spec decode(binary()) :: {t(), <<>>} + def decode(<>) do + packet = %__MODULE__{ + sha: sha + } + + {packet, ""} + end + + @doc """ + Validates input binary/plaintext with a Modification Detection Code (MDC) Packet. + Returns :ok on success. Raises on failure. + Expect last 22 octets of payload to represent MDC Packet. + """ + @spec validate!(payload :: binary()) :: {plaintext :: binary(), sha :: binary()} + def validate!("" <> _ = payload) do + plen = byte_size(payload) - byte_size(@mdc_header) - @mdc_byte_size + + {plaintext, sha_expected} = + case payload do + <> -> {plaintext, sha} + _ -> raise("Failed to parse Modification Detection Code Packet.") + end + + sha_actual = :crypto.hash(:sha, plaintext <> @mdc_header) + + if sha_actual == sha_expected do + {plaintext, sha_actual} + else + sha_expected_hex = inspect(sha_expected, base: :hex) + sha_actual_hex = inspect(sha_actual, base: :hex) + + raise("Failed to verify Modification Detection Code SHA-1: expected #{sha_expected_hex}, got #{sha_actual_hex}.") + end + end + + @doc """ + Encode Modification Detection Code (MDC) Packet and append to the input binary. + Returns binary with MDC appended. + """ + @spec append_to(input :: binary()) :: binary() + def append_to("" <> _ = input), do: input <> @mdc_header <> :crypto.hash(:sha, input <> @mdc_header) +end diff --git a/lib/open_pgp/packet.ex b/lib/open_pgp/packet.ex index f8fbacd..ed72b3c 100644 --- a/lib/open_pgp/packet.ex +++ b/lib/open_pgp/packet.ex @@ -33,7 +33,7 @@ defmodule OpenPGP.Packet do @type t :: %__MODULE__{ tag: PacketTag.t(), - body: [BodyChunk.t()] + body: [BodyChunk.t()] | binary() } @doc """ diff --git a/lib/open_pgp/public_key_encrypted_session_key_packet.ex b/lib/open_pgp/public_key_encrypted_session_key_packet.ex index e901b32..9a7776d 100644 --- a/lib/open_pgp/public_key_encrypted_session_key_packet.ex +++ b/lib/open_pgp/public_key_encrypted_session_key_packet.ex @@ -1,12 +1,21 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacket do + @v06x_note """ + As of 0.6.x Public Key Encrypted Session Key Packet: + + 1. Encrypts the session key with "Elgamal (Encrypt-Only)" algorithm only (algo 16) + 1. Decrypts the session key encrypted with "RSA (Encrypt or Sign)" algorithm only (algo 1) + """ + @moduledoc """ - Represents structured data for Public-Key Encrypted SessionKey Packet. + Represents structured data for Public-Key Encrypted Session Key Packet. The `:ciphertext` attribute is set once the packet is decoded with `.decode/1` and the packet data is still symmetrically encrypted. The next logical step is to decrypt packet with `.decrypt/2` to get symmetrically encrypted session key material. + > NOTE: #{@v06x_note} + --- ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) @@ -98,7 +107,7 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacket do } @doc """ - Decode Public-Key Encrypted SessionKey Packet given input binary. + Decode Public-Key Encrypted Session Key Packet given input binary. Return structured packet and remaining binary. """ @impl OpenPGP.Packet.Behaviour diff --git a/lib/open_pgp/public_key_packet.ex b/lib/open_pgp/public_key_packet.ex index f6df455..7eff458 100644 --- a/lib/open_pgp/public_key_packet.ex +++ b/lib/open_pgp/public_key_packet.ex @@ -137,7 +137,7 @@ defmodule OpenPGP.PublicKeyPacket do {packet, next} end - # Support only RSA as of version 0.5.x + # Support only RSA and ElGamal as of version 0.6.x defp decode_material(algo, "" <> _ = input) when algo in [1, 2, 3] do {mod_n, next} = Util.decode_mpi(input) {exp_e, rest} = Util.decode_mpi(next) @@ -145,6 +145,14 @@ defmodule OpenPGP.PublicKeyPacket do {{mod_n, exp_e}, rest} end + defp decode_material(algo, "" <> _ = input) when algo == 16 do + {prime_p, next} = Util.decode_mpi(input) + {group_g, next} = Util.decode_mpi(next) + {value_y, rest} = Util.decode_mpi(next) + + {{prime_p, group_g, value_y}, rest} + end + defp build_key_id("" <> _ = input, "" <> _ = next) do payload_length = byte_size(input) - byte_size(next) <> = input diff --git a/lib/open_pgp/util.ex b/lib/open_pgp/util.ex index 8d37ddf..1a1bda3 100644 --- a/lib/open_pgp/util.ex +++ b/lib/open_pgp/util.ex @@ -58,7 +58,7 @@ defmodule OpenPGP.Util do end @doc """ - Invers of `.decode_mpi/1`. Takes an MPI value, and encode it as MPI + Inverse of `.decode_mpi/1`. Takes an MPI value, and encode it as MPI binary. ### Example: @@ -68,19 +68,18 @@ defmodule OpenPGP.Util do iex> OpenPGP.Util.encode_mpi(<<0x1, 0xFF>>) <<0x0, 0x9, 0x1, 0xFF>> + + iex> :crypto.strong_rand_bytes(65536) |> OpenPGP.Util.encode_mpi() + ** (RuntimeError) big-endian is too long """ - @spec encode_mpi(mpi_value :: binary()) :: binary() - def encode_mpi(mpi_value) do - bits = for <>, do: bit - bsize = bit_size(mpi_value) - - mpi_length = - Enum.reduce_while(bits, bsize, fn - 1, acc -> {:halt, acc} - 0, acc -> {:cont, acc - 1} - end) - - <> + @spec encode_mpi(big_endian :: binary()) :: mpi :: <<_::16, _::_*8>> + def encode_mpi("" <> _ = big_endian) do + if byte_size(big_endian) > 65535, do: raise("big-endian is too long") + + bit_list = for <>, do: bit + bit_count = bit_list |> Enum.drop_while(&(&1 == 0)) |> length() + + <> end @doc """ @@ -263,4 +262,18 @@ defmodule OpenPGP.Util do """ @spec compression_algo_tuple(byte()) :: compression_algo_tuple() def compression_algo_tuple(algo) when algo in @comp_algo_ids, do: {algo, @comp_algos[algo]} + + @v06x_note """ + As of 0.6.x supported sym.key ciphers are: + + 1. 7 (AES with 128-bit key) in CFB mode + 1. 8 (AES with 192-bit key) in CFB mode + 1. 9 (AES with 256-bit key) in CFB mode + """ + @spec sym_algo_to_crypto_cipher(sym_algo_tuple() | byte()) :: :aes_128_cfb128 | :aes_192_cfb128 | :aes_256_cfb128 + def sym_algo_to_crypto_cipher({algo, _}), do: sym_algo_to_crypto_cipher(algo) + def sym_algo_to_crypto_cipher(7), do: :aes_128_cfb128 + def sym_algo_to_crypto_cipher(8), do: :aes_192_cfb128 + def sym_algo_to_crypto_cipher(9), do: :aes_256_cfb128 + def sym_algo_to_crypto_cipher(algo), do: raise(@v06x_note <> "\n Got: #{inspect(algo)}") end diff --git a/lib/open_pgp/util/pkcs1.ex b/lib/open_pgp/util/pkcs1.ex new file mode 100644 index 0000000..3129746 --- /dev/null +++ b/lib/open_pgp/util/pkcs1.ex @@ -0,0 +1,82 @@ +defmodule OpenPGP.Util.PKCS1 do + @moduledoc """ + Utility functions for PKCS#1 encoding/decoding + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 13.1. PKCS#1 Encoding in OpenPGP + + This standard makes use of the PKCS#1 functions EME-PKCS1-v1_5 and + EMSA-PKCS1-v1_5. However, the calling conventions of these functions + has changed in the past. To avoid potential confusion and + interoperability problems, we are including local copies in this + document, adapted from those in PKCS#1 v2.1 [RFC3447]. RFC 3447 + should be treated as the ultimate authority on PKCS#1 for OpenPGP. + Nonetheless, we believe that there is value in having a self- + contained document that avoids problems in the future with needed + changes in the conventions. + """ + + @doc """ + Encode message as described in PKCS#1 block encoding EME-PKCS1-v1_5 + in Section 7.2.1 of [RFC3447](https://www.ietf.org/rfc/rfc3447.txt) + + ### Example + + iex> key = :crypto.strong_rand_bytes(16) + ...> em = OpenPGP.Util.PKCS1.encode(:eme_pkcs1_v1_5, "Hello", key) + ...> <<0x00::8, 0x02::8, _::64, 0x00::8, "Hello">> = em + + iex> key = :crypto.strong_rand_bytes(15) + ...> OpenPGP.Util.PKCS1.encode(:eme_pkcs1_v1_5, "Hello", key) + ** (RuntimeError) message too long + + See Section 13.1 of [RFC4880] for notes on OpenPGP's use of PKCS#1. + + ### 13.1.1. EME-PKCS1-v1_5-ENCODE + + Input: + k = the length in octets of the key modulus + M = message to be encoded, an octet string of length mLen, where + mLen <= k - 11 + + Output: + EM = encoded message, an octet string of length k + Error: "message too long" + + 1. Length checking: If mLen > k - 11, output "message too long" and + stop. + + 2. Generate an octet string PS of length k - mLen - 3 consisting of + pseudo-randomly generated nonzero octets. The length of PS will + be at least eight octets. + + 3. Concatenate PS, the message M, and other padding to form an + encoded message EM of length k octets as + + EM = 0x00 || 0x02 || PS || 0x00 || M. + + 4. Output EM. + """ + @spec encode(:eme_pkcs1_v1_5, message :: binary(), key :: binary()) :: encoded_message :: binary() + def encode(:eme_pkcs1_v1_5, "" <> _ = message, "" <> _ = key) do + mlen = byte_size(message) + klen = byte_size(key) + + if mlen > klen - 11, do: raise("message too long") + + ps = generate_ps(klen - mlen - 3) + + <<0x00::8, 0x02::8, ps::binary, 0x00::8, message::binary>> + end + + @spec generate_ps(len :: pos_integer()) :: padding_string :: binary() + defp generate_ps(len) when is_integer(len) and len >= 8 do + result = :crypto.strong_rand_bytes(len) + has_zero = result |> :binary.bin_to_list() |> Enum.any?(&(&1 == 0)) + + if has_zero, + do: generate_ps(len), + else: result + end +end diff --git a/test/fixtures/elg2048-pub.pgp b/test/fixtures/elg2048-pub.pgp new file mode 100644 index 0000000..96f2d00 Binary files /dev/null and b/test/fixtures/elg2048-pub.pgp differ diff --git a/test/open_pgp/integrity_protected_data_packet_test.exs b/test/open_pgp/integrity_protected_data_packet_test.exs index 6e47ef9..5f63f5c 100644 --- a/test/open_pgp/integrity_protected_data_packet_test.exs +++ b/test/open_pgp/integrity_protected_data_packet_test.exs @@ -1,4 +1,129 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do use OpenPGP.Test.Case, async: true doctest OpenPGP.IntegrityProtectedDataPacket + doctest OpenPGP.Encode.impl_for!(%OpenPGP.IntegrityProtectedDataPacket{}) + + alias OpenPGP.Encrypt + alias OpenPGP.IntegrityProtectedDataPacket, as: IPDPacket + alias OpenPGP.PublicKeyEncryptedSessionKeyPacket, as: PKESK + + describe ".decrypt/2" do + @expected_error "Failed to parse Modification Detection Code Packet." + @algo {7, "AES with 128-bit key [AES]"} + test "raise error if MDC is missing when `use_mdc: true`" do + sym_key = :crypto.strong_rand_bytes(16) + + assert %IPDPacket{ciphertext: ciphertext} = + Encrypt.encrypt(%IPDPacket{plaintext: "Hello!"}, + session_key: sym_key, + session_key_algo: @algo, + use_mdc: false + ) + + assert_raise RuntimeError, @expected_error, fn -> + %IPDPacket{plaintext: "Hello!"} = + IPDPacket.decrypt( + %IPDPacket{ciphertext: ciphertext}, + %PKESK{session_key_algo: @algo, session_key_material: {sym_key}}, + use_mdc: true + ) + end + end + end + + describe "OpenPGP.Encrypt.encrypt/2" do + @algo {7, "AES with 128-bit key [AES]"} + test "encrypt plaintext with AES-128" do + sym_key = :crypto.strong_rand_bytes(16) + + assert %IPDPacket{ciphertext: ciphertext} = + Encrypt.encrypt(%IPDPacket{plaintext: "Hello!"}, + session_key: sym_key, + session_key_algo: @algo, + use_mdc: true + ) + + assert %IPDPacket{plaintext: "Hello!"} = + IPDPacket.decrypt( + %IPDPacket{ciphertext: ciphertext}, + %PKESK{session_key_algo: @algo, session_key_material: {sym_key}}, + use_mdc: true + ) + end + + @algo {8, "AES with 192-bit key"} + test "encrypt plaintext with AES-192" do + sym_key = :crypto.strong_rand_bytes(24) + + assert %IPDPacket{ciphertext: ciphertext} = + Encrypt.encrypt(%IPDPacket{plaintext: "Hello!"}, + session_key: sym_key, + session_key_algo: @algo, + use_mdc: true + ) + + assert %IPDPacket{plaintext: "Hello!"} = + IPDPacket.decrypt( + %IPDPacket{ciphertext: ciphertext}, + %PKESK{session_key_algo: @algo, session_key_material: {sym_key}}, + use_mdc: true + ) + end + + @algo {9, "AES with 256-bit key"} + test "encrypt plaintext with AES-256" do + sym_key = :crypto.strong_rand_bytes(32) + + assert %IPDPacket{ciphertext: ciphertext} = + Encrypt.encrypt(%IPDPacket{plaintext: "Hello!"}, + session_key: sym_key, + session_key_algo: @algo, + use_mdc: true + ) + + assert %IPDPacket{plaintext: "Hello!"} = + IPDPacket.decrypt( + %IPDPacket{ciphertext: ciphertext}, + %PKESK{session_key_algo: @algo, session_key_material: {sym_key}}, + use_mdc: true + ) + end + + @algo {7, "AES with 128-bit key [AES]"} + test "encrypt plaintext with AES-128 and no MDC" do + sym_key = :crypto.strong_rand_bytes(16) + + assert %IPDPacket{ciphertext: ciphertext} = + Encrypt.encrypt(%IPDPacket{plaintext: "Hello!"}, + session_key: sym_key, + session_key_algo: @algo, + use_mdc: false + ) + + assert %IPDPacket{plaintext: "Hello!"} = + IPDPacket.decrypt( + %IPDPacket{ciphertext: ciphertext}, + %PKESK{session_key_algo: @algo, session_key_material: {sym_key}}, + use_mdc: false + ) + end + + @algo {7, "AES with 128-bit key [AES]"} + test "encrypt plaintext with AES-128 and no MDC (default behavior of .decrypt/2)" do + sym_key = :crypto.strong_rand_bytes(16) + + assert %IPDPacket{ciphertext: ciphertext} = + Encrypt.encrypt(%IPDPacket{plaintext: "Hello!"}, + session_key: sym_key, + session_key_algo: @algo, + use_mdc: false + ) + + assert %IPDPacket{plaintext: "Hello!"} = + IPDPacket.decrypt( + %IPDPacket{ciphertext: ciphertext}, + %PKESK{session_key_algo: @algo, session_key_material: {sym_key}} + ) + end + end end diff --git a/test/open_pgp/literal_data_packet_test.exs b/test/open_pgp/literal_data_packet_test.exs index 9a5a8b6..2639f35 100644 --- a/test/open_pgp/literal_data_packet_test.exs +++ b/test/open_pgp/literal_data_packet_test.exs @@ -1,4 +1,43 @@ defmodule OpenPGP.LiteralDataPacketTest do use OpenPGP.Test.Case, async: true doctest OpenPGP.LiteralDataPacket + doctest OpenPGP.Encode.impl_for!(%OpenPGP.LiteralDataPacket{}) + + alias OpenPGP.Encode + alias OpenPGP.LiteralDataPacket + + describe "OpenPGP.Encode.encode/1,2" do + test "encode plaintext with default opts" do + packet = %LiteralDataPacket{data: "Hello"} + assert <<0x62, 0, unix_ts::32, "Hello">> = Encode.encode(packet) + assert_in_delta unix_ts, System.os_time(:second), 2 + end + + test "encode plaintext and set file name" do + packet = %LiteralDataPacket{file_name: "file.txt", data: "Hello"} + assert <<0x62, 8, "file.txt", _::32, "Hello">> = Encode.encode(packet) + end + + @ts 1_704_328_052 + test "encode plaintext and set timestamp" do + packet = %LiteralDataPacket{data: "Hello", created_at: DateTime.from_unix!(@ts)} + assert <<0x62, 0, @ts::32, "Hello">> = Encode.encode(packet) + end + + test "encode plaintext and set format" do + assert <<0x62, 0, _::32, "Hello">> = Encode.encode(%LiteralDataPacket{data: "Hello", format: :binary}) + assert <<0x74, 0, _::32, "Hello">> = Encode.encode(%LiteralDataPacket{data: "Hello", format: :text}) + assert <<0x75, 0, _::32, "Hello">> = Encode.encode(%LiteralDataPacket{data: "Hello", format: :text_utf8}) + end + + @expected_error """ + Unknown Literal Data Packet format: :invalid. + Known formats: [:binary, :text, :text_utf8] + """ + test "raise when format not valid" do + assert_raise RuntimeError, @expected_error, fn -> + Encode.encode(%LiteralDataPacket{data: "Hello", format: :invalid}) + end + end + end end diff --git a/test/open_pgp/modification_detection_code_packet_test.exs b/test/open_pgp/modification_detection_code_packet_test.exs new file mode 100644 index 0000000..89ddce1 --- /dev/null +++ b/test/open_pgp/modification_detection_code_packet_test.exs @@ -0,0 +1,39 @@ +defmodule OpenPGP.ModificationDetectionCodePacketTest do + use OpenPGP.Test.Case, async: true + doctest OpenPGP.ModificationDetectionCodePacket + + alias OpenPGP.ModificationDetectionCodePacket + + describe ".validate!/2" do + @mdc_header <<0xD3, 0x14>> + test "returns :ok if SHA-1 match" do + sha = :crypto.hash(:sha, "Hello!" <> @mdc_header) + assert {"Hello!", ^sha} = ModificationDetectionCodePacket.validate!("Hello!" <> @mdc_header <> sha) + end + + @expected_error "Failed to verify Modification Detection Code SHA-1: " <> + "expected <<0x18, 0x7C, 0xC0, 0xEE, 0x16, 0x5E, 0xDB, 0x92, 0x49, 0x3, 0xDC, 0x91, 0x82, 0x2, 0xB8, 0x3C, 0xF5, 0xE3, 0x2C, 0x11>>, " <> + "got <<0xCC, 0x63, 0x18, 0xDF, 0xBE, 0x6F, 0x4F, 0xAE, 0x27, 0xEA, 0xE, 0x74, 0x12, 0xFD, 0x60, 0x2, 0x49, 0x6, 0x42, 0xF4>>." + test "raises error if SHA-1 does not match" do + sha = :crypto.hash(:sha, "Hello!" <> @mdc_header) + + assert_raise RuntimeError, @expected_error, fn -> + ModificationDetectionCodePacket.validate!("Bye!" <> @mdc_header <> sha) + end + end + + @expected_error "Failed to parse Modification Detection Code Packet." + test "raises error if no Modification Detection Code packet" do + assert_raise RuntimeError, @expected_error, fn -> + ModificationDetectionCodePacket.validate!("Bye!") + end + end + end + + describe ".append_to/1" do + test "return input binary with MDC appended" do + assert <<"Hello", 0xD3, 0x14, 227, 31, 247, 172, 136, 16, 149, 100, 94, 82, 223, 75, 23, 29, 209, 110, 63, 139, + 153, 38>> = ModificationDetectionCodePacket.append_to("Hello") + end + end +end diff --git a/test/open_pgp/packet/body_chunk_test.exs b/test/open_pgp/packet/body_chunk_test.exs index 250650b..2163e0f 100644 --- a/test/open_pgp/packet/body_chunk_test.exs +++ b/test/open_pgp/packet/body_chunk_test.exs @@ -1,6 +1,7 @@ defmodule OpenPGP.Packet.BodyChunkTest do use OpenPGP.Test.Case, async: true doctest OpenPGP.Packet.BodyChunk + doctest OpenPGP.Encode.impl_for!(%OpenPGP.Packet.BodyChunk{}) alias OpenPGP.Packet.BodyChunk, as: BChunk alias OpenPGP.Packet.PacketTag, as: PacketTag diff --git a/test/open_pgp/packet/packet_tag_test.exs b/test/open_pgp/packet/packet_tag_test.exs index e7f7fa1..9d89aaa 100644 --- a/test/open_pgp/packet/packet_tag_test.exs +++ b/test/open_pgp/packet/packet_tag_test.exs @@ -1,6 +1,7 @@ defmodule OpenPGP.Packet.PacketTagTest do use OpenPGP.Test.Case, async: true doctest OpenPGP.Packet.PacketTag + doctest OpenPGP.Encode.impl_for!(%OpenPGP.Packet.PacketTag{}) alias OpenPGP.Packet.PacketTag diff --git a/test/open_pgp/packet_test.exs b/test/open_pgp/packet_test.exs index bedd172..d3e1ed2 100644 --- a/test/open_pgp/packet_test.exs +++ b/test/open_pgp/packet_test.exs @@ -1,4 +1,5 @@ defmodule OpenPGP.PacketTest do use OpenPGP.Test.Case, async: true doctest OpenPGP.Packet + doctest OpenPGP.Encode.impl_for!(%OpenPGP.Packet{}) end diff --git a/test/open_pgp/public_key_encrypted_session_key_packet_test.exs b/test/open_pgp/public_key_encrypted_session_key_packet_test.exs index 4b546b2..c64df90 100644 --- a/test/open_pgp/public_key_encrypted_session_key_packet_test.exs +++ b/test/open_pgp/public_key_encrypted_session_key_packet_test.exs @@ -1,9 +1,14 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do use OpenPGP.Test.Case, async: true + doctest OpenPGP.PublicKeyEncryptedSessionKeyPacket + doctest OpenPGP.Encode.impl_for!(%OpenPGP.PublicKeyEncryptedSessionKeyPacket{}) + alias OpenPGP.Encode + alias OpenPGP.Encrypt alias OpenPGP.Packet alias OpenPGP.Packet.PacketTag alias OpenPGP.PublicKeyEncryptedSessionKeyPacket + alias OpenPGP.PublicKeyPacket alias OpenPGP.SecretKeyPacket alias OpenPGP.Util @@ -33,6 +38,18 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do end end + describe ".encode/3" do + test "encodes packet body" do + packet = %PublicKeyEncryptedSessionKeyPacket{ + ciphertext: "Ciphertext", + public_key_id: "6BAF2C48", + public_key_algo: {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} + } + + assert <<3::8, "6BAF2C48", 16::8, "Ciphertext">> == Encode.encode(packet) + end + end + describe ".decrypt/2" do test "decrypts key material given a valid decrypted Secret-Key Packet" do [ @@ -63,4 +80,58 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do assert "26A582ACA833B8EE60CC58865D19A21653D38CB0737125C9ABF973405E3B233C" = Base.encode16(m_e_mod_n) end end + + describe "OpenPGP.Encrypt.encrypt/2" do + # [RFC3526](https://datatracker.ietf.org/doc/html/rfc3526) + @modp_group_1536 """ + FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 + 29024E08 8A67CC74 020BBEA6 3B139B22 514A0879 8E3404DD + EF9519B3 CD3A431B 302B0A6D F25F1437 4FE1356D 6D51C245 + E485B576 625E7EC6 F44C42E9 A637ED6B 0BFF5CB6 F406B7ED + EE386BFB 5A899FA5 AE9F2411 7C4B1FE6 49286651 ECE45B3D + C2007CB8 A163BF05 98DA4836 1C55D39A 69163FA8 FD24CF5F + 83655D23 DCA3AD96 1C62F356 208552BB 9ED52907 7096966D + 670C354E 4ABC9804 F1746C08 CA237327 FFFFFFFF FFFFFFFF + """ + @prime_p @modp_group_1536 |> String.replace(~r/[^0-9ABCDEF]/, "") |> Base.decode16!() + @group_g <<2::8>> + test "encrypts AES-256 session key with Elgamal" do + alias PublicKeyEncryptedSessionKeyPacket, as: PKESK + + # Define Diffie-Hellman (DH) parameters (p and g). These are commonly used predefined values. + p = :binary.decode_unsigned(@prime_p) + g = :binary.decode_unsigned(@group_g) + + # Generate private key such as "1 < private_key < p-1" + # Any 1024-bit (128 bytes) big-endian will be smaller than 1536-bit big-endian. + private_key = :crypto.strong_rand_bytes(128) + a = :binary.decode_unsigned(private_key) + + # Generate the public key exp (g**private_key mod p) + e = :crypto.mod_pow(g, a, p) + + recipient_public_key = %PublicKeyPacket{ + algo: {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"}, + material: {@prime_p, @group_g, e} + } + + pkesk_packet = %PKESK{ + session_key_algo: {9, "AES with 256-bit key"}, + session_key_material: {"12345678901234567890123456789012"} + } + + assert %PKESK{ciphertext: ciphertext} = Encrypt.encrypt(pkesk_packet, recipient_public_key: recipient_public_key) + + # Decrypt Elgamal + assert {c1, next} = Util.decode_mpi(ciphertext) + assert {c2, <<>>} = Util.decode_mpi(next) + + x = :crypto.mod_pow(c1, a, p) + y = :crypto.mod_pow(x, p - 2, p) + decoded_value = rem(:binary.decode_unsigned(c2) * :binary.decode_unsigned(y), p) + plaintext = :binary.encode_unsigned(decoded_value) + + assert [_, <<0x09, "12345678901234567890123456789012", _::16>>] = String.split(plaintext, <<0>>, parts: 2) + end + end end diff --git a/test/open_pgp/public_key_packet_test.exs b/test/open_pgp/public_key_packet_test.exs index b58b8f1..e9a5390 100644 --- a/test/open_pgp/public_key_packet_test.exs +++ b/test/open_pgp/public_key_packet_test.exs @@ -5,6 +5,7 @@ defmodule OpenPGP.PublicKeyPacketTest do alias OpenPGP.PublicKeyPacket @rsa2048_priv File.read!("test/fixtures/rsa2048-priv.pgp") + @elg2048_pub File.read!("test/fixtures/elg2048-pub.pgp") test ".decode/1 decodes RSA Public-Key packet" do assert [%Packet{body: chunks, tag: %PacketTag{tag: {5, "Secret-Key Packet"}}} | _] = @@ -33,4 +34,40 @@ defmodule OpenPGP.PublicKeyPacketTest do assert "010001" == Base.encode16(exp_e) end + + test ".decode/1 decodes ELG Public-Key (sub-key) packet" do + packet = @elg2048_pub |> OpenPGP.list_packets() |> Enum.at(3) + + assert %Packet{body: chunks, tag: %PacketTag{tag: {14, "Public-Subkey Packet"}}} = packet + + data = OpenPGP.Util.concat_body(chunks) + + assert {%PublicKeyPacket{ + algo: {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"}, + created_at: ~U[2025-01-30 20:51:55Z], + expires: nil, + material: {prime_p, group_g, value_y}, + version: 4 + }, <<>>} = PublicKeyPacket.decode(data) + + assert "95288533E970D6FB1D923EEBBF723EA3CD52C4AB20EBDE0D9DC5E3E40FF609BC65BFA28EB65" <> + "8EC5C44E08444B8C4AF67AA4B96457453CA773518766C1E536084AA1DCCEFC5006D670552" <> + "EC704AFC4830DF50BF67AA14F0A1E8C6A8CBE27E5AEB64AC6FA264F802B8821B10302F627" <> + "960AC39F4DA87A584A98F4D07341C3F1294FE99E18BAC766D464D98C96DE9F79CC462D4D7" <> + "A8B6F818CB88DDF0BD100B97C7E38F37FCAAE231775EF03DC431A72C071EA0E86D2A1C73F" <> + "3A74D2A9B977AD0FC44CDCCD09C54091879191757581AC095A56E7D8BD7F59B9F5C34139C" <> + "E317C900D327F8601CC9ECF4A5F4073668D44C9A507A7624B06852DA20EF2C56A930EECF" == + Base.encode16(prime_p) + + assert "0B" == Base.encode16(group_g) + + assert "5D04024065BF1E52E7BCA39CCD6D4FE6BB0DA4E631094A84B0508014441AB8AE421FC87A7B9" <> + "49B01269CA968653B237FD5BE3A9CB24BC3E86F2C88CA8738001184829DBBD2AB73B1B5EF" <> + "BC6748F60A030C7B1257397786072541CBA2CD367627DE24E2B396027D156C38F786B71FD" <> + "8D544DCA8F73E17339019E56B092445F9BBA373CE41435EA525EDA0356DB14886705195D7" <> + "335859C9B16D3599DECA2C1070B9E2FEE983BF1A42CC4B48740A3903FA59762733384E17A" <> + "BB918F06DB37877E594D6C04CC28BE1EF9D6011E0655716CA507DEABDCA6E23B08079F915" <> + "2CAC73CCC53706BE856E26D02D0A7F1AB8122614E91000F5EB1B240F50C66D9048F861D3" == + Base.encode16(value_y) + end end diff --git a/test/open_pgp/util/pkcs1_test.exs b/test/open_pgp/util/pkcs1_test.exs new file mode 100644 index 0000000..063cc88 --- /dev/null +++ b/test/open_pgp/util/pkcs1_test.exs @@ -0,0 +1,4 @@ +defmodule OpenPGP.Util.PKCS1Test do + use OpenPGP.Test.Case, async: true + doctest OpenPGP.Util.PKCS1 +end diff --git a/test/open_pgp_test.exs b/test/open_pgp_test.exs index c2d9ed7..70bbb1f 100644 --- a/test/open_pgp_test.exs +++ b/test/open_pgp_test.exs @@ -18,6 +18,13 @@ defmodule OpenPGPTest do Generate keyring with RSA2048 algo: `gpg --batch --passphrase "passphrase" --quick-generate-key "John Doe (RSA2048) " rsa2048 default never` + Generate private key with DSA2048 algo and ELG2046 sub-key: + + ``` + gpg --batch --passphrase "passphrase" --quick-generate-key "John Doe (ELG2048) " dsa2048 default never + gpg --quick-add-key elg2048 default never + ``` + Export private key: `gpg --export-secret-keys "john.doe@example.com" > test/fixtures/rsa2048-priv.pgp`