From ffb51a345234bdec730d786cfcf4326bc6c3352c Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Thu, 30 Jan 2025 14:28:45 -0500 Subject: [PATCH 01/12] Refactored MPI encoding. Added exception for too long big-endian numbers --- lib/open_pgp/util.ex | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/lib/open_pgp/util.ex b/lib/open_pgp/util.ex index 8d37ddf..eb5c9a4 100644 --- a/lib/open_pgp/util.ex +++ b/lib/open_pgp/util.ex @@ -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 """ From 8cfc665f5b8bd82094e6cda7fc6f8b12a0f92c53 Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Thu, 30 Jan 2025 16:27:38 -0500 Subject: [PATCH 02/12] Add ElGamal algo support to OpenPGP.PublicKeyPacket --- lib/open_pgp/public_key_packet.ex | 10 +++++- test/fixtures/elg2048-pub.pgp | Bin 0 -> 1667 bytes test/open_pgp/public_key_packet_test.exs | 37 +++++++++++++++++++++++ test/open_pgp_test.exs | 7 +++++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/elg2048-pub.pgp 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/test/fixtures/elg2048-pub.pgp b/test/fixtures/elg2048-pub.pgp new file mode 100644 index 0000000000000000000000000000000000000000..96f2d0045ae73db3145cc88e53c2409a5fb4ff29 GIT binary patch literal 1667 zcmV-}27LLM11 zXd!e;=!vi2hdjO}ANK(Oj5Mjo;%hwD0kMFG30#5CN6&DMWajg3I$%?y6O}H>2mfAa zlHySPY^6^>{n^`$jvLjQwc_u9b5H_TJ?Y54Q3(?wa#?gLmrVu4V6ef~`rz`jo#srw zV;z7fMK^kfXQMOc+zDunCOWJ4MRbn*G%XzoPG_R5tVI2fZw`yGUqD;+ZA}+<5#As= ziyo=aYh~KgQS!e}>qsQt^7ae99%*mI>Fi&*-v4)6X(<0}M z^GN4v{nvWc5BUibj<^ElF<;3R1XA5&TPlBU`0e#a<5TrkcbI`=NlOGkOVbDcODPb= z+AX*(k)t`C-&NsI>D6CW&NE9lkE~_lNC-=e$otfrtGiSiSUUN0zVAe?9e4ZkgF|tf zf|rs#A1(-M4@&U_sYHH-!|jQiiZ}I0_Aah7S~342@S&{7y)wnp79e8s^;y082X6+> z+^to&z=HV8Mb_0+GCg3uUW+XC&U5G{n&P)7m3_V|YLL6wV9aMtjp0F|4+055)`R19 zcyOz&H8{L_^UTcJ4svs5z1hfSyO6mvI;6d}&2+vB;l39&{feHeSc>k-U6n34@n!}~ zZgu5QcYv@uF=Xl@`x zZ)G4TMNCIBFf=$RAUtYsXl^cKZ)GlJY-chsG&n$IcwudDY-KKEZ*4w^lLQkH2mm`4 zAq0-C-B|_BjbVioz4=!mz!%K9vmgZmXPf3B0viJb3ke7Z0s)s8W#pC1;_6fXYzoL$|SnOOx;DkiD#II+nOO{1+Q_6QW z75ftZ$-Z_yV|s8xSxr zVtHV!IrQ3xrG%-EO$Rg_KN6Jwnc<79c5OyYn8|JF_ngFH)YquC_!!HG-SE8-3zx^^ zk2n0P;xTt#@IAyarz{5^py+KX9CP!hO)8t0deHnt&CJl8R0$Y)85dVr8o-sMZhec_ z^_!nuG!vZT7s&wAC-`6-$?Wu{^anO*)J&RCdUhnRXj0lB?<`iSFz(L)1PcfMT?7I^ zWxpO$=e(nw&23NSyA7n~F$qe9uuy;$L>joRLLbO_dz702CY-5gWjiB()xJ8MvP;A0 zZ!Cz)hd2Ncgo2&B(yMc^weP%VNcIW?40{q+Id_H!B|*!g%{F!?-X!9)mI8ehY&iFZ zw;$NmMBJ$NJ{L2P8RcsUBt`kVqjSzdLtdpN?VvSnu}Fq+QI*#-SXs%jZ8e$R$}A9Y zx#IrmgTER=%u7gg3ONJ%S#~EgI8GOEL$Eed}cxDqB5=@0<*>l-8wP{wVLNcdsXhz~$YI{fqeCpw NQa{g<9@7iiw|gnZ8%_WK literal 0 HcmV?d00001 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_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` From 523923e21acd270b24d01212a0614778bc7e8cbe Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Thu, 30 Jan 2025 19:01:20 -0500 Subject: [PATCH 03/12] Add LiteralDataPacket.encode/1,2 --- lib/open_pgp/literal_data_packet.ex | 41 ++++++++++++++++++++++ test/open_pgp/literal_data_packet_test.exs | 34 ++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/lib/open_pgp/literal_data_packet.ex b/lib/open_pgp/literal_data_packet.ex index 43b3238..b2c7ea9 100644 --- a/lib/open_pgp/literal_data_packet.ex +++ b/lib/open_pgp/literal_data_packet.ex @@ -104,4 +104,45 @@ defmodule OpenPGP.LiteralDataPacket do {packet, ""} end + + @doc "See `encode/2`" + @spec encode(data :: binary()) :: binary() + def encode("" <> _ = data), do: encode(data, []) + + @doc """ + Encode Literal Data Packet given input binary. + Return encoded binary. + + Options: + + - `:format` - describes how the encoded data is formatted. Valid values - `:binary`, `:text`, `:text_utf8`. Default: `:binary` + - `:file_name` - the name of the encrypted file. Default: `nil` + - `:created_at` - the date and time associated with the data. Default: `System.os_time(:second)` + """ + @spec encode(data :: binary(), opts :: Keyword.t()) :: binary() + def encode("" <> _ = data, opts) do + formats = Map.new(@formats, fn {k, v} -> {v, k} end) + format = Keyword.get(opts, :format, :binary) + + format_byte = + Map.get(formats, format) || + raise """ + Unknown Literal Data Packet format: #{inspect(format)}. + Known formats: #{inspect(Map.keys(formats))} + """ + + fname_string = + case Keyword.get(opts, :file_name) do + fname when is_binary(fname) and byte_size(fname) > 0 -> <> + _ -> <<0::8>> + end + + timestamp = + case Keyword.get(opts, :created_at) do + %DateTime{} = date -> DateTime.to_unix(date) + nil -> System.os_time(:second) + 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..e142fe5 100644 --- a/test/open_pgp/literal_data_packet_test.exs +++ b/test/open_pgp/literal_data_packet_test.exs @@ -1,4 +1,38 @@ defmodule OpenPGP.LiteralDataPacketTest do use OpenPGP.Test.Case, async: true doctest OpenPGP.LiteralDataPacket + + alias OpenPGP.LiteralDataPacket + + describe ".encode/2" do + test "encode plaintext with default opts" do + assert <<0x62, 0, unix_ts::32, "Hello">> = LiteralDataPacket.encode("Hello") + assert_in_delta unix_ts, System.os_time(:second), 2 + end + + test "encode plaintext and set file name" do + assert <<0x62, 8, "file.txt", _::32, "Hello">> = LiteralDataPacket.encode("Hello", file_name: "file.txt") + end + + @ts 1_704_328_052 + test "encode plaintext and set timestamp" do + assert <<0x62, 0, @ts::32, "Hello">> = LiteralDataPacket.encode("Hello", created_at: DateTime.from_unix!(@ts)) + end + + test "encode plaintext and set format" do + assert <<0x62, 0, _::32, "Hello">> = LiteralDataPacket.encode("Hello", format: :binary) + assert <<0x74, 0, _::32, "Hello">> = LiteralDataPacket.encode("Hello", format: :text) + assert <<0x75, 0, _::32, "Hello">> = LiteralDataPacket.encode("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 -> + LiteralDataPacket.encode("Hello", format: :invalid) + end + end + end end From cd5595ba68cdbeed2f26b5f54cfb688c67ec437a Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Fri, 31 Jan 2025 16:04:01 -0500 Subject: [PATCH 04/12] Add Packet.encode/1 PacketTag.encode/1 BodyChunk.encode/1 --- lib/open_pgp/packet.ex | 14 +++++++++++ lib/open_pgp/packet/body_chunk.ex | 30 ++++++++++++++++++++++++ lib/open_pgp/packet/packet_tag.ex | 16 +++++++++++++ test/open_pgp/packet/body_chunk_test.exs | 16 +++++++++++++ test/open_pgp/packet/packet_tag_test.exs | 10 ++++++++ 5 files changed, 86 insertions(+) diff --git a/lib/open_pgp/packet.ex b/lib/open_pgp/packet.ex index f8fbacd..3550070 100644 --- a/lib/open_pgp/packet.ex +++ b/lib/open_pgp/packet.ex @@ -66,6 +66,20 @@ defmodule OpenPGP.Packet do {packet, rest} end + @doc """ + Encode packet given a packet tag tuple (or integer) and an input binary (packet body). + Return encoded packet binary - a packet header, followed by the packet body. + + ### Example: + + iex> OpenPGP.Packet.encode(11, "Hello, World!!!") + <<1::1, 1::1, 11::6, 15::8, "Hello, World!!!">> + """ + @spec encode(PacketTag.tag_tuple() | non_neg_integer(), input :: binary()) :: binary() + def encode(ptag, "" <> _ = input) do + PacketTag.encode(ptag) <> BodyChunk.encode(input) + end + @spec collect_chunks(input :: binary(), PacketTag.t(), acc :: [BodyChunk.t()]) :: {[BodyChunk.t()], rest :: binary()} defp collect_chunks("" <> _ = input, %PacketTag{} = ptag, acc) when is_list(acc) do diff --git a/lib/open_pgp/packet/body_chunk.ex b/lib/open_pgp/packet/body_chunk.ex index 3e46b92..7f81e3e 100644 --- a/lib/open_pgp/packet/body_chunk.ex +++ b/lib/open_pgp/packet/body_chunk.ex @@ -143,6 +143,36 @@ defmodule OpenPGP.Packet.BodyChunk do {chunk, rest} end + @doc """ + Encode body chunk given input binary. Always uses new packet format. + Return encoded body chunk with the length header prefix. + """ + @spec encode(input :: binary()) :: binary() + def encode("" <> _ = input) do + blen = byte_size(input) + + hlen = + cond do + blen in 0..191 -> + <> + + blen in 192..8383 -> + <> = <> + <> + + blen in 8384..0xFFFFFFFF -> + <<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 <> input + end + @spec length_header(data :: binary(), PTag.t()) :: {header_length(), chunk_length(), rest :: binary()} defp length_header(<>, %PTag{format: :old, length_type: {0, _}}) do diff --git a/lib/open_pgp/packet/packet_tag.ex b/lib/open_pgp/packet/packet_tag.ex index fd2c2fb..81fbf6e 100644 --- a/lib/open_pgp/packet/packet_tag.ex +++ b/lib/open_pgp/packet/packet_tag.ex @@ -125,6 +125,22 @@ defmodule OpenPGP.Packet.PacketTag do {ptag, rest} end + @doc """ + Encode packet tag. Always uses new packet format. + Return encoded packet tag octet. + + ### Example: + + iex> OpenPGP.Packet.PacketTag.encode(1) + <<1::1, 1::1, 1::6>> + + iex> OpenPGP.Packet.PacketTag.encode({2, "Signature Packet"}) + <<1::1, 1::1, 2::6>> + """ + @spec encode(tag_tuple() | non_neg_integer()) :: <<_::8>> + def encode({ptag, _name}), do: encode(ptag) + def encode(ptag) when is_integer(ptag) and ptag in 0..63, do: <<1::1, 1::1, ptag::6>> + @ptags %{ 0 => "Reserved - a packet tag MUST NOT have this value", 1 => "Public-Key Encrypted Session Key Packet", diff --git a/test/open_pgp/packet/body_chunk_test.exs b/test/open_pgp/packet/body_chunk_test.exs index 250650b..2721434 100644 --- a/test/open_pgp/packet/body_chunk_test.exs +++ b/test/open_pgp/packet/body_chunk_test.exs @@ -169,4 +169,20 @@ defmodule OpenPGP.Packet.BodyChunkTest do assert pt1 <> rest == rand_bytes end end + + describe ".encode/1" do + test "encodes one-octet length New Format Packet Length Header (up to 191 octets)" do + assert <<12::8, "Hello world!">> == BChunk.encode("Hello world!") + end + + test "encodes two-octet length New Format Packet Length Header (192-8383 octets)" do + rand_bytes = :crypto.strong_rand_bytes(255) + assert <<192::8, 63::8, rand_bytes::binary>> == BChunk.encode(rand_bytes) + end + + test "encodes five-octet length New Format Packet Length Header (8384-4,294,967,295 octets)" do + rand_bytes = :crypto.strong_rand_bytes(8384) + assert <<255::8, 8384::32, rand_bytes::binary>> == BChunk.encode(rand_bytes) + end + end end diff --git a/test/open_pgp/packet/packet_tag_test.exs b/test/open_pgp/packet/packet_tag_test.exs index e7f7fa1..c33ef86 100644 --- a/test/open_pgp/packet/packet_tag_test.exs +++ b/test/open_pgp/packet/packet_tag_test.exs @@ -21,4 +21,14 @@ defmodule OpenPGP.Packet.PacketTagTest do }, ""} = PacketTag.decode(<<1::1, 1::1, 5::6>>) end end + + describe ".encode/1" do + test "encodes new packet format with integer" do + assert <<1::1, 1::1, 1::6>> = PacketTag.encode(1) + end + + test "encodes new packet format with tuple" do + assert <<1::1, 1::1, 1::6>> = PacketTag.encode({1, "Public-Key Encrypted Session Key Packet"}) + end + end end From f5a96ed71483364c725788307569b712f419eb55 Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Fri, 31 Jan 2025 16:16:36 -0500 Subject: [PATCH 05/12] Updated CHANGELOG.md --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 825c32f..4211add 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1,13 @@ # Changelog + +## v0.6.0 + +### Enhancements + +* Add encoding feature to: + * `OpenPGP.LiteralDataPacket.encode/1,2` + * `OpenPGP.Packet.encode/1` + * `OpenPGP.Packet.PacketTag.encode/1` + * `OpenPGP.Packet.BodyChunk.encode/1` +* Add ElGamal algorithm support to `OpenPGP.PublicKeyPacket.decode/1`. +* Refactored `OpenPGP.Util.encode_mpi/1` and added exception for too long big-endian numbers (>65535 octets). From 3c254ef065fdd2869647deae54a48a21b5e62d70 Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Mon, 3 Feb 2025 21:03:37 -0500 Subject: [PATCH 06/12] Added IntegrityProtectedDataPacket.encode/1 IntegrityProtectedDataPacket.encrypt/2,3. Added MDC support. --- CHANGELOG.md | 3 + lib/open_pgp.ex | 1 + .../integrity_protected_data_packet.ex | 139 ++++++++++++++---- .../modification_detection_code_packet.ex | 114 ++++++++++++++ .../integrity_protected_data_packet_test.exs | 64 ++++++++ ...odification_detection_code_packet_test.exs | 32 ++++ 6 files changed, 322 insertions(+), 31 deletions(-) create mode 100644 lib/open_pgp/modification_detection_code_packet.ex create mode 100644 test/open_pgp/modification_detection_code_packet_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4211add..b9ae7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,12 @@ ### Enhancements * Add encoding feature to: + * `OpenPGP.IntegrityProtectedDataPacket.encode/1` * `OpenPGP.LiteralDataPacket.encode/1,2` * `OpenPGP.Packet.encode/1` * `OpenPGP.Packet.PacketTag.encode/1` * `OpenPGP.Packet.BodyChunk.encode/1` +* Added `OpenPGP.IntegrityProtectedDataPacket.ecrypt/2,3` with AES-128, AES-192, AES-256 (Sym.algo 7,8,9) * Add ElGamal algorithm support to `OpenPGP.PublicKeyPacket.decode/1`. +* Introduced `OpenPGP.ModificationDetectionCodePacket` * Refactored `OpenPGP.Util.encode_mpi/1` and added exception for too long big-endian numbers (>65535 octets). diff --git a/lib/open_pgp.ex b/lib/open_pgp.ex index 091d87b..4c47ba9 100644 --- a/lib/open_pgp.ex +++ b/lib/open_pgp.ex @@ -85,6 +85,7 @@ defmodule OpenPGP do | OpenPGP.CompressedDataPacket.t() | OpenPGP.IntegrityProtectedDataPacket.t() | OpenPGP.LiteralDataPacket.t() + | OpenPGP.ModificationDetectionCodePacket.t() @doc """ Decode all packets in a message (input). diff --git a/lib/open_pgp/integrity_protected_data_packet.ex b/lib/open_pgp/integrity_protected_data_packet.ex index 6418ba6..ce585fb 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,128 @@ 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 if set to `true` and raises on failure (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 = 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: plaintext} + end + + @doc """ + Encodes a Sym. Encrypted and Integrity Protected Data Packet given input binary. + Returns encoded packet body. + + ### Example: + + iex> alias OpenPGP.IntegrityProtectedDataPacket + ...> IntegrityProtectedDataPacket.encode("Hello") + <<1::8, "Hello">> + """ + @version 1 + @spec encode(input :: binary()) :: binary() + def encode("" <> _ = input), do: <<@version::8, input::binary>> + + @doc """ + Encrypt plaintext binary with a given symmetrical algorithm. + Returns a tuple with ciphertext and sym.key used for encyption. + Accepts options keyword list as a third argument (optional): - %{packet | plaintext: data} + - `:use_mdc` - the Modification Detection Code Packet added if set to `true` (default `true`) + """ + @spec encrypt(plaintext, algo, opts) :: {ciphertext, sym_key} + when plaintext: binary(), + algo: Util.sym_algo_tuple() | byte(), + ciphertext: binary(), + sym_key: binary(), + opts: [{:use_mdc, boolean()}] + def encrypt(plaintext, algo, opts \\ []) do + key_size = Util.sym_algo_key_size(algo) + crypto_cipher = sym_algo_to_crypto_cipher(algo) + sym_key = :crypto.strong_rand_bytes(div(key_size, 8)) + null_iv = build_null_iv(algo) + checksum = build_checksum(algo) + + data = + if Keyword.get(opts, :use_mdc, true), + do: MDC.append_to(checksum <> plaintext), + else: checksum <> plaintext + + ciphertext = :crypto.crypto_one_time(crypto_cipher, sym_key, null_iv, data, true) + + {ciphertext, sym_key} 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 + @spec sym_algo_to_crypto_cipher(Util.sym_algo_tuple() | byte()) :: :aes_128_cfb128 | :aes_192_cfb128 | :aes_256_cfb128 + defp sym_algo_to_crypto_cipher({algo, _}), do: sym_algo_to_crypto_cipher(algo) + defp sym_algo_to_crypto_cipher(7), do: :aes_128_cfb128 + defp sym_algo_to_crypto_cipher(8), do: :aes_192_cfb128 + defp sym_algo_to_crypto_cipher(9), do: :aes_256_cfb128 + defp sym_algo_to_crypto_cipher(algo), do: raise(@v06x_note <> "\n Got: #{inspect(algo)}") - << - _::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 + + @checksum_size 2 * 8 + defp 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) + @spec build_null_iv(Util.sym_algo_tuple() | byte()) :: binary() + defp 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..6bd21bc --- /dev/null +++ b/lib/open_pgp/modification_detection_code_packet.ex @@ -0,0 +1,114 @@ +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 Packet given input binary. + Returns encoded packet body. + """ + @spec encode(input :: binary()) :: <<_::160>> + def encode("" <> _ = input), do: :crypto.hash(:sha, input <> @mdc_header) + + @doc """ + Encode Modification Detection Code (MDC) Packet ad appends to the input binary. + Returns binary with MDC appended. + """ + @spec append_to(input :: binary()) :: binary() + def append_to("" <> _ = input), do: input <> @mdc_header <> encode(input) +end diff --git a/test/open_pgp/integrity_protected_data_packet_test.exs b/test/open_pgp/integrity_protected_data_packet_test.exs index 6e47ef9..a669972 100644 --- a/test/open_pgp/integrity_protected_data_packet_test.exs +++ b/test/open_pgp/integrity_protected_data_packet_test.exs @@ -1,4 +1,68 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do use OpenPGP.Test.Case, async: true doctest OpenPGP.IntegrityProtectedDataPacket + + alias OpenPGP.IntegrityProtectedDataPacket, as: IPDPacket + alias OpenPGP.PublicKeyEncryptedSessionKeyPacket, as: PKESK + + describe ".encrypt/2,3" do + @algo {7, "AES with 128-bit key [AES]"} + test "encrypt plaintext with AES-128" do + assert {ciphertext, sym_key} = IPDPacket.encrypt("Hello!", @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 + assert {ciphertext, sym_key} = IPDPacket.encrypt("Hello!", @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 + assert {ciphertext, sym_key} = IPDPacket.encrypt("Hello!", @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 + assert {ciphertext, sym_key} = IPDPacket.encrypt("Hello!", @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 + assert {ciphertext, sym_key} = IPDPacket.encrypt("Hello!", @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/modification_detection_code_packet_test.exs b/test/open_pgp/modification_detection_code_packet_test.exs new file mode 100644 index 0000000..47bd58a --- /dev/null +++ b/test/open_pgp/modification_detection_code_packet_test.exs @@ -0,0 +1,32 @@ +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 +end From db14e5e37a6ab178242f5f100e50a7db0cc72a1f Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Tue, 4 Feb 2025 21:38:54 -0500 Subject: [PATCH 07/12] Added PublicKeyEncryptedSessionKeyPacket.encode/3 PublicKeyEncryptedSessionKeyPacket.ecrypt/4. Introduced Util.PKCS1.encode/3 --- CHANGELOG.md | 3 + ...public_key_encrypted_session_key_packet.ex | 89 ++++++++++++++++++- lib/open_pgp/util/pkcs1.ex | 82 +++++++++++++++++ ..._key_encrypted_session_key_packet_test.exs | 62 +++++++++++++ test/open_pgp/util/pkcs1_test.exs | 4 + 5 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 lib/open_pgp/util/pkcs1.ex create mode 100644 test/open_pgp/util/pkcs1_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index b9ae7d2..501a4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,15 @@ ### Enhancements * Add encoding feature to: + * `OpenPGP.PublicKeyEncryptedSessionKeyPacket.encode/3` * `OpenPGP.IntegrityProtectedDataPacket.encode/1` * `OpenPGP.LiteralDataPacket.encode/1,2` * `OpenPGP.Packet.encode/1` * `OpenPGP.Packet.PacketTag.encode/1` * `OpenPGP.Packet.BodyChunk.encode/1` * Added `OpenPGP.IntegrityProtectedDataPacket.ecrypt/2,3` with AES-128, AES-192, AES-256 (Sym.algo 7,8,9) +* Added `OpenPGP.PublicKeyEncryptedSessionKeyPacket.ecrypt/4` with Elgamal (Public-Key algo 16) * 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/lib/open_pgp/public_key_encrypted_session_key_packet.ex b/lib/open_pgp/public_key_encrypted_session_key_packet.ex index e901b32..c7ff786 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 @@ -116,6 +125,25 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacket do {packet, ""} end + @doc """ + Encode Public-Key Encrypted Session Key Packet given input ciphertext, public key ID and public key algo. + Return Public-Key Encrypted Session Key Packet binary. + + ### Example + + iex> alias OpenPGP.PublicKeyEncryptedSessionKeyPacket, as: PKESK + ...> ciphertext = "Ciphertext" + ...> recipient_public_key_id = "6BAF2C48" + ...> recipient_public_key_algo = {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} + ...> PKESK.encode(ciphertext, recipient_public_key_id, recipient_public_key_algo) + <<3::8, recipient_public_key_id::binary, 16::8, ciphertext::binary>> + """ + @version 3 + @spec encode(ciphertext :: binary(), public_key_id :: binary(), Util.public_key_algo_tuple()) :: binary() + def encode("" <> _ = ciphertext, "" <> _ = public_key_id, {pub_key_algo_id, _}) do + <<@version::8, public_key_id::binary, pub_key_algo_id::8, ciphertext::binary>> + end + @doc """ Decrypt Public-Key Encrypted Session Key Packet given decoded Public-Key Encrypted Session Key Packet and decoded and decrypted @@ -156,4 +184,61 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacket do raise(msg) end end + + @doc """ + Encrypt session key with a given public key. + Require session key algo as it will be encoded and encrypted as well. + Require public key algo as it will be used to generate public key material. + Return ciphertext binary (encrypted session key), which consist of algorithm specific encrypted MPIs. + """ + @spec encrypt(session_key, session_key_algo, public_key_material, public_key_algo) :: ciphertext + when session_key: binary(), + session_key_algo: Util.sym_algo_tuple(), + public_key_material: tuple(), + public_key_algo: Util.public_key_algo_tuple(), + ciphertext: binary() + def encrypt("" <> _ = session_key, session_key_algo, public_key_material, public_key_algo) do + material = build_material(session_key, session_key_algo, public_key_material, public_key_algo) + + for el <- Tuple.to_list(material), reduce: "" do + acc -> acc <> Util.encode_mpi(el) + end + end + + # {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} + @spec build_material(session_key, session_key_algo, public_key_material, public_key_algo) :: pkesk_material + when session_key: binary(), + session_key_algo: Util.sym_algo_tuple(), + public_key_material: tuple(), + public_key_algo: Util.public_key_algo_tuple(), + pkesk_material: tuple() + 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 + + 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/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/open_pgp/public_key_encrypted_session_key_packet_test.exs b/test/open_pgp/public_key_encrypted_session_key_packet_test.exs index 4b546b2..5622acf 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,5 +1,6 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do use OpenPGP.Test.Case, async: true + doctest OpenPGP.PublicKeyEncryptedSessionKeyPacket alias OpenPGP.Packet alias OpenPGP.Packet.PacketTag @@ -33,6 +34,17 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do end end + describe ".encode/3" do + test "encodes packet body" do + ciphertext = "Ciphertext" + public_key_id = "6BAF2C48" + public_key_algo = {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} + + assert body = PublicKeyEncryptedSessionKeyPacket.encode(ciphertext, public_key_id, public_key_algo) + assert <<3::8, public_key_id::binary, 16::8, ciphertext::binary>> == body + end + end + describe ".decrypt/2" do test "decrypts key material given a valid decrypted Secret-Key Packet" do [ @@ -63,4 +75,54 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do assert "26A582ACA833B8EE60CC58865D19A21653D38CB0737125C9ABF973405E3B233C" = Base.encode16(m_e_mod_n) end end + + describe ".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 (g**private_key mod p) + e = :crypto.mod_pow(g, a, p) + + public_key_material = {@prime_p, @group_g, e} + public_key_algo = {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} + + session_key_algo = {9, "AES with 256-bit key"} + session_key = "12345678901234567890123456789012" + + assert ciphertext = PKESK.encrypt(session_key, session_key_algo, public_key_material, public_key_algo) + + # 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/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 From dfe0fbc0ef130cbd73f4b98fedc6a8e8d283e138 Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Tue, 4 Feb 2025 22:02:17 -0500 Subject: [PATCH 08/12] Updated IntegrityProtectedDataPacket.encrypt to accept sym.key (not to generate it) --- CHANGELOG.md | 2 +- .../integrity_protected_data_packet.ex | 22 +++++++++---------- .../integrity_protected_data_packet_test.exs | 16 +++++++++----- 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 501a4e5..e9771b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ * `OpenPGP.Packet.encode/1` * `OpenPGP.Packet.PacketTag.encode/1` * `OpenPGP.Packet.BodyChunk.encode/1` -* Added `OpenPGP.IntegrityProtectedDataPacket.ecrypt/2,3` with AES-128, AES-192, AES-256 (Sym.algo 7,8,9) +* Added `OpenPGP.IntegrityProtectedDataPacket.ecrypt/3,4` with AES-128, AES-192, AES-256 (Sym.algo 7,8,9) * Added `OpenPGP.PublicKeyEncryptedSessionKeyPacket.ecrypt/4` with Elgamal (Public-Key algo 16) * Add ElGamal algorithm support to `OpenPGP.PublicKeyPacket.decode/1`. * Introduced `OpenPGP.ModificationDetectionCodePacket` diff --git a/lib/open_pgp/integrity_protected_data_packet.ex b/lib/open_pgp/integrity_protected_data_packet.ex index ce585fb..9468261 100644 --- a/lib/open_pgp/integrity_protected_data_packet.ex +++ b/lib/open_pgp/integrity_protected_data_packet.ex @@ -193,24 +193,22 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do def encode("" <> _ = input), do: <<@version::8, input::binary>> @doc """ - Encrypt plaintext binary with a given symmetrical algorithm. - Returns a tuple with ciphertext and sym.key used for encyption. + Encrypt plaintext binary with a given symmetrical key and algorithm. + Returns a ciphertext binary encrypted with a given sym.key. Accepts options keyword list as a third argument (optional): - `:use_mdc` - the Modification Detection Code Packet added if set to `true` (default `true`) """ - @spec encrypt(plaintext, algo, opts) :: {ciphertext, sym_key} + @spec encrypt(plaintext, sym_key, sym_algo, opts) :: ciphertext when plaintext: binary(), - algo: Util.sym_algo_tuple() | byte(), - ciphertext: binary(), sym_key: binary(), + sym_algo: Util.sym_algo_tuple() | byte(), + ciphertext: binary(), opts: [{:use_mdc, boolean()}] - def encrypt(plaintext, algo, opts \\ []) do - key_size = Util.sym_algo_key_size(algo) - crypto_cipher = sym_algo_to_crypto_cipher(algo) - sym_key = :crypto.strong_rand_bytes(div(key_size, 8)) - null_iv = build_null_iv(algo) - checksum = build_checksum(algo) + def encrypt(plaintext, sym_key, sym_algo, opts \\ []) do + crypto_cipher = sym_algo_to_crypto_cipher(sym_algo) + null_iv = build_null_iv(sym_algo) + checksum = build_checksum(sym_algo) data = if Keyword.get(opts, :use_mdc, true), @@ -219,7 +217,7 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do ciphertext = :crypto.crypto_one_time(crypto_cipher, sym_key, null_iv, data, true) - {ciphertext, sym_key} + ciphertext end @spec sym_algo_to_crypto_cipher(Util.sym_algo_tuple() | byte()) :: :aes_128_cfb128 | :aes_192_cfb128 | :aes_256_cfb128 diff --git a/test/open_pgp/integrity_protected_data_packet_test.exs b/test/open_pgp/integrity_protected_data_packet_test.exs index a669972..bccf32a 100644 --- a/test/open_pgp/integrity_protected_data_packet_test.exs +++ b/test/open_pgp/integrity_protected_data_packet_test.exs @@ -8,7 +8,8 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do describe ".encrypt/2,3" do @algo {7, "AES with 128-bit key [AES]"} test "encrypt plaintext with AES-128" do - assert {ciphertext, sym_key} = IPDPacket.encrypt("Hello!", @algo, use_mdc: true) + sym_key = :crypto.strong_rand_bytes(16) + assert ciphertext = IPDPacket.encrypt("Hello!", sym_key, @algo, use_mdc: true) assert %IPDPacket{plaintext: "Hello!"} = IPDPacket.decrypt( @@ -20,7 +21,8 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do @algo {8, "AES with 192-bit key"} test "encrypt plaintext with AES-192" do - assert {ciphertext, sym_key} = IPDPacket.encrypt("Hello!", @algo, use_mdc: true) + sym_key = :crypto.strong_rand_bytes(24) + assert ciphertext = IPDPacket.encrypt("Hello!", sym_key, @algo, use_mdc: true) assert %IPDPacket{plaintext: "Hello!"} = IPDPacket.decrypt( @@ -32,7 +34,8 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do @algo {9, "AES with 256-bit key"} test "encrypt plaintext with AES-256" do - assert {ciphertext, sym_key} = IPDPacket.encrypt("Hello!", @algo, use_mdc: true) + sym_key = :crypto.strong_rand_bytes(32) + assert ciphertext = IPDPacket.encrypt("Hello!", sym_key, @algo, use_mdc: true) assert %IPDPacket{plaintext: "Hello!"} = IPDPacket.decrypt( @@ -44,7 +47,9 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do @algo {7, "AES with 128-bit key [AES]"} test "encrypt plaintext with AES-128 and no MDC" do - assert {ciphertext, sym_key} = IPDPacket.encrypt("Hello!", @algo, use_mdc: false) + sym_key = :crypto.strong_rand_bytes(16) + + assert ciphertext = IPDPacket.encrypt("Hello!", sym_key, @algo, use_mdc: false) assert %IPDPacket{plaintext: "Hello!"} = IPDPacket.decrypt( @@ -56,7 +61,8 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do @algo {7, "AES with 128-bit key [AES]"} test "encrypt plaintext with AES-128 and no MDC (default behavior of .decrypt/2)" do - assert {ciphertext, sym_key} = IPDPacket.encrypt("Hello!", @algo, use_mdc: false) + sym_key = :crypto.strong_rand_bytes(16) + assert ciphertext = IPDPacket.encrypt("Hello!", sym_key, @algo, use_mdc: false) assert %IPDPacket{plaintext: "Hello!"} = IPDPacket.decrypt( From bdf944af9f73e1530d9200146cf7e8a90784c7ee Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Wed, 5 Feb 2025 16:03:01 -0500 Subject: [PATCH 09/12] Using protocols --- CHANGELOG.md | 24 +++++---- README.md | 4 ++ lib/open_pgp.ex | 27 +++++++--- lib/open_pgp/encode.ex | 15 ++++++ .../integrity_protected_data_packet_impl.ex | 18 +++++++ .../encode/literal_data_packet_impl.ex | 53 +++++++++++++++++++ lib/open_pgp/encode/packet/body_chunk_impl.ex | 53 +++++++++++++++++++ lib/open_pgp/encode/packet/packet_tag_impl.ex | 23 ++++++++ lib/open_pgp/encode/packet_impl.ex | 28 ++++++++++ ...c_key_encrypted_session_key_packet_impl.ex | 25 +++++++++ .../integrity_protected_data_packet.ex | 16 +----- lib/open_pgp/literal_data_packet.ex | 41 -------------- .../modification_detection_code_packet.ex | 11 +--- lib/open_pgp/packet.ex | 16 +----- lib/open_pgp/packet/body_chunk.ex | 30 ----------- lib/open_pgp/packet/packet_tag.ex | 16 ------ ...public_key_encrypted_session_key_packet.ex | 19 ------- .../integrity_protected_data_packet_test.exs | 1 + test/open_pgp/literal_data_packet_test.exs | 21 +++++--- ...odification_detection_code_packet_test.exs | 7 +++ test/open_pgp/packet/body_chunk_test.exs | 17 +----- test/open_pgp/packet/packet_tag_test.exs | 11 +--- test/open_pgp/packet_test.exs | 1 + ..._key_encrypted_session_key_packet_test.exs | 13 +++-- 24 files changed, 287 insertions(+), 203 deletions(-) create mode 100644 lib/open_pgp/encode.ex create mode 100644 lib/open_pgp/encode/integrity_protected_data_packet_impl.ex create mode 100644 lib/open_pgp/encode/literal_data_packet_impl.ex create mode 100644 lib/open_pgp/encode/packet/body_chunk_impl.ex create mode 100644 lib/open_pgp/encode/packet/packet_tag_impl.ex create mode 100644 lib/open_pgp/encode/packet_impl.ex create mode 100644 lib/open_pgp/encode/public_key_encrypted_session_key_packet_impl.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index e9771b2..28ad3a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,18 @@ ### Enhancements -* Add encoding feature to: - * `OpenPGP.PublicKeyEncryptedSessionKeyPacket.encode/3` - * `OpenPGP.IntegrityProtectedDataPacket.encode/1` - * `OpenPGP.LiteralDataPacket.encode/1,2` - * `OpenPGP.Packet.encode/1` - * `OpenPGP.Packet.PacketTag.encode/1` - * `OpenPGP.Packet.BodyChunk.encode/1` -* Added `OpenPGP.IntegrityProtectedDataPacket.ecrypt/3,4` with AES-128, AES-192, AES-256 (Sym.algo 7,8,9) -* Added `OpenPGP.PublicKeyEncryptedSessionKeyPacket.ecrypt/4` with Elgamal (Public-Key algo 16) +* 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` +* Added `OpenPGP.IntegrityProtectedDataPacket.ecrypt/3,4` with AES-128, AES-192, AES-256 (Sym.algo 7,8,9). +* Added `OpenPGP.PublicKeyEncryptedSessionKeyPacket.ecrypt/4` with Elgamal (Public-Key algo 16). +* Added `OpenPGP.encode_packet/1` that delegate to `OpenPGP.Encode` 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 +* 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 4c47ba9..cae0ab2 100644 --- a/lib/open_pgp.ex +++ b/lib/open_pgp.ex @@ -73,19 +73,20 @@ defmodule OpenPGP do ] """ + alias __MODULE__.Encode 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.ModificationDetectionCodePacket.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). @@ -146,4 +147,14 @@ defmodule OpenPGP do packet end end + + @doc "Encode any packet (except for %Packet{})." + @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 end diff --git a/lib/open_pgp/encode.ex b/lib/open_pgp/encode.ex new file mode 100644 index 0000000..e3d3136 --- /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(_), do: raise(".tag/1 not implemented for #{inspect(@for)}.") + def encode(_, _), do: raise(".encode/2 not implemented for #{inspect(@for)}.") +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..1621c16 --- /dev/null +++ b/lib/open_pgp/encode/packet/body_chunk_impl.ex @@ -0,0 +1,53 @@ +defimpl OpenPGP.Encode, for: OpenPGP.Packet.BodyChunk do + alias OpenPGP.Packet.BodyChunk + + def tag(_), do: raise(".tag/1 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 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>> + """ + def encode(%BodyChunk{data: "" <> _ = data}, _opts) do + blen = byte_size(data) + + hlen = + cond do + blen in 0..191 -> + <> + + blen in 192..8383 -> + <> = <> + <> + + blen in 8384..0xFFFFFFFF -> + <<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..4a2ba3b --- /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 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..cb01beb --- /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 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/integrity_protected_data_packet.ex b/lib/open_pgp/integrity_protected_data_packet.ex index 9468261..9db6cac 100644 --- a/lib/open_pgp/integrity_protected_data_packet.ex +++ b/lib/open_pgp/integrity_protected_data_packet.ex @@ -151,7 +151,7 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do Raises an error if checksum does not match. Accepts options keyword list as a third argument (optional): - - `:use_mdc` - validates Modification Detection Code Packet if set to `true` and raises on failure (default: `false`) + - `:use_mdc` - validates Modification Detection Code Packet and raises on failure if set to `true` (default: `false`) """ @spec decrypt(t(), PKESK.t(), opts :: [{:use_mdc, boolean()}]) :: t() def decrypt(%__MODULE__{} = packet, %PKESK{} = pkesk, opts \\ []) do @@ -178,20 +178,6 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do %{packet | plaintext: plaintext} end - @doc """ - Encodes a Sym. Encrypted and Integrity Protected Data Packet given input binary. - Returns encoded packet body. - - ### Example: - - iex> alias OpenPGP.IntegrityProtectedDataPacket - ...> IntegrityProtectedDataPacket.encode("Hello") - <<1::8, "Hello">> - """ - @version 1 - @spec encode(input :: binary()) :: binary() - def encode("" <> _ = input), do: <<@version::8, input::binary>> - @doc """ Encrypt plaintext binary with a given symmetrical key and algorithm. Returns a ciphertext binary encrypted with a given sym.key. diff --git a/lib/open_pgp/literal_data_packet.ex b/lib/open_pgp/literal_data_packet.ex index b2c7ea9..43b3238 100644 --- a/lib/open_pgp/literal_data_packet.ex +++ b/lib/open_pgp/literal_data_packet.ex @@ -104,45 +104,4 @@ defmodule OpenPGP.LiteralDataPacket do {packet, ""} end - - @doc "See `encode/2`" - @spec encode(data :: binary()) :: binary() - def encode("" <> _ = data), do: encode(data, []) - - @doc """ - Encode Literal Data Packet given input binary. - Return encoded binary. - - Options: - - - `:format` - describes how the encoded data is formatted. Valid values - `:binary`, `:text`, `:text_utf8`. Default: `:binary` - - `:file_name` - the name of the encrypted file. Default: `nil` - - `:created_at` - the date and time associated with the data. Default: `System.os_time(:second)` - """ - @spec encode(data :: binary(), opts :: Keyword.t()) :: binary() - def encode("" <> _ = data, opts) do - formats = Map.new(@formats, fn {k, v} -> {v, k} end) - format = Keyword.get(opts, :format, :binary) - - format_byte = - Map.get(formats, format) || - raise """ - Unknown Literal Data Packet format: #{inspect(format)}. - Known formats: #{inspect(Map.keys(formats))} - """ - - fname_string = - case Keyword.get(opts, :file_name) do - fname when is_binary(fname) and byte_size(fname) > 0 -> <> - _ -> <<0::8>> - end - - timestamp = - case Keyword.get(opts, :created_at) do - %DateTime{} = date -> DateTime.to_unix(date) - nil -> System.os_time(:second) - end - - <> - end end diff --git a/lib/open_pgp/modification_detection_code_packet.ex b/lib/open_pgp/modification_detection_code_packet.ex index 6bd21bc..b7a46c4 100644 --- a/lib/open_pgp/modification_detection_code_packet.ex +++ b/lib/open_pgp/modification_detection_code_packet.ex @@ -99,16 +99,9 @@ defmodule OpenPGP.ModificationDetectionCodePacket do end @doc """ - Encode Modification Detection Code Packet given input binary. - Returns encoded packet body. - """ - @spec encode(input :: binary()) :: <<_::160>> - def encode("" <> _ = input), do: :crypto.hash(:sha, input <> @mdc_header) - - @doc """ - Encode Modification Detection Code (MDC) Packet ad appends to the input binary. + 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 <> encode(input) + 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 3550070..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 """ @@ -66,20 +66,6 @@ defmodule OpenPGP.Packet do {packet, rest} end - @doc """ - Encode packet given a packet tag tuple (or integer) and an input binary (packet body). - Return encoded packet binary - a packet header, followed by the packet body. - - ### Example: - - iex> OpenPGP.Packet.encode(11, "Hello, World!!!") - <<1::1, 1::1, 11::6, 15::8, "Hello, World!!!">> - """ - @spec encode(PacketTag.tag_tuple() | non_neg_integer(), input :: binary()) :: binary() - def encode(ptag, "" <> _ = input) do - PacketTag.encode(ptag) <> BodyChunk.encode(input) - end - @spec collect_chunks(input :: binary(), PacketTag.t(), acc :: [BodyChunk.t()]) :: {[BodyChunk.t()], rest :: binary()} defp collect_chunks("" <> _ = input, %PacketTag{} = ptag, acc) when is_list(acc) do diff --git a/lib/open_pgp/packet/body_chunk.ex b/lib/open_pgp/packet/body_chunk.ex index 7f81e3e..3e46b92 100644 --- a/lib/open_pgp/packet/body_chunk.ex +++ b/lib/open_pgp/packet/body_chunk.ex @@ -143,36 +143,6 @@ defmodule OpenPGP.Packet.BodyChunk do {chunk, rest} end - @doc """ - Encode body chunk given input binary. Always uses new packet format. - Return encoded body chunk with the length header prefix. - """ - @spec encode(input :: binary()) :: binary() - def encode("" <> _ = input) do - blen = byte_size(input) - - hlen = - cond do - blen in 0..191 -> - <> - - blen in 192..8383 -> - <> = <> - <> - - blen in 8384..0xFFFFFFFF -> - <<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 <> input - end - @spec length_header(data :: binary(), PTag.t()) :: {header_length(), chunk_length(), rest :: binary()} defp length_header(<>, %PTag{format: :old, length_type: {0, _}}) do diff --git a/lib/open_pgp/packet/packet_tag.ex b/lib/open_pgp/packet/packet_tag.ex index 81fbf6e..fd2c2fb 100644 --- a/lib/open_pgp/packet/packet_tag.ex +++ b/lib/open_pgp/packet/packet_tag.ex @@ -125,22 +125,6 @@ defmodule OpenPGP.Packet.PacketTag do {ptag, rest} end - @doc """ - Encode packet tag. Always uses new packet format. - Return encoded packet tag octet. - - ### Example: - - iex> OpenPGP.Packet.PacketTag.encode(1) - <<1::1, 1::1, 1::6>> - - iex> OpenPGP.Packet.PacketTag.encode({2, "Signature Packet"}) - <<1::1, 1::1, 2::6>> - """ - @spec encode(tag_tuple() | non_neg_integer()) :: <<_::8>> - def encode({ptag, _name}), do: encode(ptag) - def encode(ptag) when is_integer(ptag) and ptag in 0..63, do: <<1::1, 1::1, ptag::6>> - @ptags %{ 0 => "Reserved - a packet tag MUST NOT have this value", 1 => "Public-Key Encrypted Session Key Packet", 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 c7ff786..5125d14 100644 --- a/lib/open_pgp/public_key_encrypted_session_key_packet.ex +++ b/lib/open_pgp/public_key_encrypted_session_key_packet.ex @@ -125,25 +125,6 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacket do {packet, ""} end - @doc """ - Encode Public-Key Encrypted Session Key Packet given input ciphertext, public key ID and public key algo. - Return Public-Key Encrypted Session Key Packet binary. - - ### Example - - iex> alias OpenPGP.PublicKeyEncryptedSessionKeyPacket, as: PKESK - ...> ciphertext = "Ciphertext" - ...> recipient_public_key_id = "6BAF2C48" - ...> recipient_public_key_algo = {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} - ...> PKESK.encode(ciphertext, recipient_public_key_id, recipient_public_key_algo) - <<3::8, recipient_public_key_id::binary, 16::8, ciphertext::binary>> - """ - @version 3 - @spec encode(ciphertext :: binary(), public_key_id :: binary(), Util.public_key_algo_tuple()) :: binary() - def encode("" <> _ = ciphertext, "" <> _ = public_key_id, {pub_key_algo_id, _}) do - <<@version::8, public_key_id::binary, pub_key_algo_id::8, ciphertext::binary>> - end - @doc """ Decrypt Public-Key Encrypted Session Key Packet given decoded Public-Key Encrypted Session Key Packet and decoded and decrypted diff --git a/test/open_pgp/integrity_protected_data_packet_test.exs b/test/open_pgp/integrity_protected_data_packet_test.exs index bccf32a..7ee0828 100644 --- a/test/open_pgp/integrity_protected_data_packet_test.exs +++ b/test/open_pgp/integrity_protected_data_packet_test.exs @@ -1,6 +1,7 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do use OpenPGP.Test.Case, async: true doctest OpenPGP.IntegrityProtectedDataPacket + doctest OpenPGP.Encode.impl_for!(%OpenPGP.IntegrityProtectedDataPacket{}) alias OpenPGP.IntegrityProtectedDataPacket, as: IPDPacket alias OpenPGP.PublicKeyEncryptedSessionKeyPacket, as: PKESK diff --git a/test/open_pgp/literal_data_packet_test.exs b/test/open_pgp/literal_data_packet_test.exs index e142fe5..2639f35 100644 --- a/test/open_pgp/literal_data_packet_test.exs +++ b/test/open_pgp/literal_data_packet_test.exs @@ -1,28 +1,33 @@ 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 ".encode/2" do + describe "OpenPGP.Encode.encode/1,2" do test "encode plaintext with default opts" do - assert <<0x62, 0, unix_ts::32, "Hello">> = LiteralDataPacket.encode("Hello") + 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 - assert <<0x62, 8, "file.txt", _::32, "Hello">> = LiteralDataPacket.encode("Hello", file_name: "file.txt") + 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 - assert <<0x62, 0, @ts::32, "Hello">> = LiteralDataPacket.encode("Hello", created_at: DateTime.from_unix!(@ts)) + 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">> = LiteralDataPacket.encode("Hello", format: :binary) - assert <<0x74, 0, _::32, "Hello">> = LiteralDataPacket.encode("Hello", format: :text) - assert <<0x75, 0, _::32, "Hello">> = LiteralDataPacket.encode("Hello", format: :text_utf8) + 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 """ @@ -31,7 +36,7 @@ defmodule OpenPGP.LiteralDataPacketTest do """ test "raise when format not valid" do assert_raise RuntimeError, @expected_error, fn -> - LiteralDataPacket.encode("Hello", format: :invalid) + Encode.encode(%LiteralDataPacket{data: "Hello", format: :invalid}) 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 index 47bd58a..89ddce1 100644 --- a/test/open_pgp/modification_detection_code_packet_test.exs +++ b/test/open_pgp/modification_detection_code_packet_test.exs @@ -29,4 +29,11 @@ defmodule OpenPGP.ModificationDetectionCodePacketTest do 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 2721434..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 @@ -169,20 +170,4 @@ defmodule OpenPGP.Packet.BodyChunkTest do assert pt1 <> rest == rand_bytes end end - - describe ".encode/1" do - test "encodes one-octet length New Format Packet Length Header (up to 191 octets)" do - assert <<12::8, "Hello world!">> == BChunk.encode("Hello world!") - end - - test "encodes two-octet length New Format Packet Length Header (192-8383 octets)" do - rand_bytes = :crypto.strong_rand_bytes(255) - assert <<192::8, 63::8, rand_bytes::binary>> == BChunk.encode(rand_bytes) - end - - test "encodes five-octet length New Format Packet Length Header (8384-4,294,967,295 octets)" do - rand_bytes = :crypto.strong_rand_bytes(8384) - assert <<255::8, 8384::32, rand_bytes::binary>> == BChunk.encode(rand_bytes) - end - end end diff --git a/test/open_pgp/packet/packet_tag_test.exs b/test/open_pgp/packet/packet_tag_test.exs index c33ef86..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 @@ -21,14 +22,4 @@ defmodule OpenPGP.Packet.PacketTagTest do }, ""} = PacketTag.decode(<<1::1, 1::1, 5::6>>) end end - - describe ".encode/1" do - test "encodes new packet format with integer" do - assert <<1::1, 1::1, 1::6>> = PacketTag.encode(1) - end - - test "encodes new packet format with tuple" do - assert <<1::1, 1::1, 1::6>> = PacketTag.encode({1, "Public-Key Encrypted Session Key Packet"}) - end - end end 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 5622acf..382625c 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,7 +1,9 @@ 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.Packet alias OpenPGP.Packet.PacketTag alias OpenPGP.PublicKeyEncryptedSessionKeyPacket @@ -36,12 +38,13 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do describe ".encode/3" do test "encodes packet body" do - ciphertext = "Ciphertext" - public_key_id = "6BAF2C48" - public_key_algo = {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} + packet = %PublicKeyEncryptedSessionKeyPacket{ + ciphertext: "Ciphertext", + public_key_id: "6BAF2C48", + public_key_algo: {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} + } - assert body = PublicKeyEncryptedSessionKeyPacket.encode(ciphertext, public_key_id, public_key_algo) - assert <<3::8, public_key_id::binary, 16::8, ciphertext::binary>> == body + assert <<3::8, "6BAF2C48", 16::8, "Ciphertext">> == Encode.encode(packet) end end From b6bc4ef6173fb200dfc69822e03384265d1c8a78 Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Thu, 6 Feb 2025 12:54:04 -0500 Subject: [PATCH 10/12] Address Daniel Flanagan feedback --- lib/open_pgp/encode/packet/body_chunk_impl.ex | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/open_pgp/encode/packet/body_chunk_impl.ex b/lib/open_pgp/encode/packet/body_chunk_impl.ex index 1621c16..634ddc5 100644 --- a/lib/open_pgp/encode/packet/body_chunk_impl.ex +++ b/lib/open_pgp/encode/packet/body_chunk_impl.ex @@ -20,25 +20,28 @@ defimpl OpenPGP.Encode, for: OpenPGP.Packet.BodyChunk do ...> 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 octets) + 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 0..191 -> + blen in @one_octet_length -> <> - blen in 192..8383 -> + blen in @two_octet_length -> <> = <> <> - blen in 8384..0xFFFFFFFF -> + blen in @five_octet_length -> <<255::8, blen::32>> true -> From 3190a475f30c32e3d0de0d51624b8372112d95d1 Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Thu, 6 Feb 2025 14:00:32 -0500 Subject: [PATCH 11/12] Typo --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28ad3a6..a85641a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,8 @@ * `OpenPGP.Packet` * `OpenPGP.Packet.PacketTag` * `OpenPGP.Packet.BodyChunk` -* Added `OpenPGP.IntegrityProtectedDataPacket.ecrypt/3,4` with AES-128, AES-192, AES-256 (Sym.algo 7,8,9). -* Added `OpenPGP.PublicKeyEncryptedSessionKeyPacket.ecrypt/4` with Elgamal (Public-Key algo 16). +* Added `OpenPGP.IntegrityProtectedDataPacket.encrypt/3,4` with AES-128, AES-192, AES-256 (Sym.algo 7,8,9). +* Added `OpenPGP.PublicKeyEncryptedSessionKeyPacket.encrypt/4` with Elgamal (Public-Key algo 16). * Added `OpenPGP.encode_packet/1` that delegate to `OpenPGP.Encode` protocol. * Add ElGamal algorithm support to `OpenPGP.PublicKeyPacket.decode/1`. * Introduced `OpenPGP.ModificationDetectionCodePacket`. From e35e1f1a37cc435c12b4e977e10dcd16d1fb9173 Mon Sep 17 00:00:00 2001 From: Pavel Tsiukhtsiayeu Date: Thu, 6 Feb 2025 23:50:51 -0500 Subject: [PATCH 12/12] Introduced OpenPGP.Encrypt protocol --- CHANGELOG.md | 7 +- lib/open_pgp.ex | 7 +- lib/open_pgp/encode.ex | 4 +- lib/open_pgp/encode/packet/body_chunk_impl.ex | 2 +- lib/open_pgp/encode/packet/packet_tag_impl.ex | 2 +- lib/open_pgp/encode/packet_impl.ex | 2 +- lib/open_pgp/encrypt.ex | 11 +++ .../integrity_protected_data_packet_impl.ex | 49 +++++++++++ ...c_key_encrypted_session_key_packet_impl.ex | 85 +++++++++++++++++++ .../integrity_protected_data_packet.ex | 62 ++++++-------- ...public_key_encrypted_session_key_packet.ex | 57 ------------- lib/open_pgp/util.ex | 16 +++- .../integrity_protected_data_packet_test.exs | 66 ++++++++++++-- ..._key_encrypted_session_key_packet_test.exs | 20 +++-- 14 files changed, 273 insertions(+), 117 deletions(-) create mode 100644 lib/open_pgp/encrypt.ex create mode 100644 lib/open_pgp/encrypt/integrity_protected_data_packet_impl.ex create mode 100644 lib/open_pgp/encrypt/public_key_encrypted_session_key_packet_impl.ex diff --git a/CHANGELOG.md b/CHANGELOG.md index a85641a..ec4d9a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,12 @@ * `OpenPGP.Packet` * `OpenPGP.Packet.PacketTag` * `OpenPGP.Packet.BodyChunk` -* Added `OpenPGP.IntegrityProtectedDataPacket.encrypt/3,4` with AES-128, AES-192, AES-256 (Sym.algo 7,8,9). -* Added `OpenPGP.PublicKeyEncryptedSessionKeyPacket.encrypt/4` with Elgamal (Public-Key algo 16). +* 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. diff --git a/lib/open_pgp.ex b/lib/open_pgp.ex index cae0ab2..02c8341 100644 --- a/lib/open_pgp.ex +++ b/lib/open_pgp.ex @@ -74,6 +74,7 @@ defmodule OpenPGP do """ alias __MODULE__.Encode + alias __MODULE__.Encrypt alias __MODULE__.Packet alias __MODULE__.Packet.PacketTag alias __MODULE__.Util @@ -148,7 +149,7 @@ defmodule OpenPGP do end end - @doc "Encode any packet (except for %Packet{})." + @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) @@ -157,4 +158,8 @@ defmodule OpenPGP do 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 index e3d3136..f821f2a 100644 --- a/lib/open_pgp/encode.ex +++ b/lib/open_pgp/encode.ex @@ -10,6 +10,6 @@ end # 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(_), do: raise(".tag/1 not implemented for #{inspect(@for)}.") - def encode(_, _), do: raise(".encode/2 not implemented for #{inspect(@for)}.") + 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/packet/body_chunk_impl.ex b/lib/open_pgp/encode/packet/body_chunk_impl.ex index 634ddc5..2d3f6ec 100644 --- a/lib/open_pgp/encode/packet/body_chunk_impl.ex +++ b/lib/open_pgp/encode/packet/body_chunk_impl.ex @@ -1,7 +1,7 @@ defimpl OpenPGP.Encode, for: OpenPGP.Packet.BodyChunk do alias OpenPGP.Packet.BodyChunk - def tag(_), do: raise(".tag/1 not supported by design for #{inspect(@for)}.") + 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. diff --git a/lib/open_pgp/encode/packet/packet_tag_impl.ex b/lib/open_pgp/encode/packet/packet_tag_impl.ex index 4a2ba3b..35fd71d 100644 --- a/lib/open_pgp/encode/packet/packet_tag_impl.ex +++ b/lib/open_pgp/encode/packet/packet_tag_impl.ex @@ -1,7 +1,7 @@ defimpl OpenPGP.Encode, for: OpenPGP.Packet.PacketTag do alias OpenPGP.Packet.PacketTag - def tag(_), do: raise(".tag/1 not supported by design for #{inspect(@for)}.") + 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. diff --git a/lib/open_pgp/encode/packet_impl.ex b/lib/open_pgp/encode/packet_impl.ex index cb01beb..fee404e 100644 --- a/lib/open_pgp/encode/packet_impl.ex +++ b/lib/open_pgp/encode/packet_impl.ex @@ -2,7 +2,7 @@ defimpl OpenPGP.Encode, for: OpenPGP.Packet do alias OpenPGP.Packet alias OpenPGP.Packet.BodyChunk - def tag(_), do: raise(".tag/1 not supported by design for #{inspect(@for)}.") + def tag(_), do: raise(".tag/1 of protocol #{inspect(@protocol)} not supported by design for #{inspect(@for)}.") @doc """ Encode a Packet. 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 9db6cac..350f1e8 100644 --- a/lib/open_pgp/integrity_protected_data_packet.ex +++ b/lib/open_pgp/integrity_protected_data_packet.ex @@ -156,7 +156,7 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do @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 = sym_algo_to_crypto_cipher(sym_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 @@ -178,41 +178,6 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do %{packet | plaintext: plaintext} end - @doc """ - Encrypt plaintext binary with a given symmetrical key and algorithm. - Returns a ciphertext binary encrypted with a given sym.key. - Accepts options keyword list as a third argument (optional): - - - `:use_mdc` - the Modification Detection Code Packet added if set to `true` (default `true`) - """ - @spec encrypt(plaintext, sym_key, sym_algo, opts) :: ciphertext - when plaintext: binary(), - sym_key: binary(), - sym_algo: Util.sym_algo_tuple() | byte(), - ciphertext: binary(), - opts: [{:use_mdc, boolean()}] - def encrypt(plaintext, sym_key, sym_algo, opts \\ []) do - crypto_cipher = sym_algo_to_crypto_cipher(sym_algo) - null_iv = build_null_iv(sym_algo) - checksum = build_checksum(sym_algo) - - data = - if Keyword.get(opts, :use_mdc, true), - do: MDC.append_to(checksum <> plaintext), - else: checksum <> plaintext - - ciphertext = :crypto.crypto_one_time(crypto_cipher, sym_key, null_iv, data, true) - - ciphertext - end - - @spec sym_algo_to_crypto_cipher(Util.sym_algo_tuple() | byte()) :: :aes_128_cfb128 | :aes_192_cfb128 | :aes_256_cfb128 - defp sym_algo_to_crypto_cipher({algo, _}), do: sym_algo_to_crypto_cipher(algo) - defp sym_algo_to_crypto_cipher(7), do: :aes_128_cfb128 - defp sym_algo_to_crypto_cipher(8), do: :aes_192_cfb128 - defp sym_algo_to_crypto_cipher(9), do: :aes_256_cfb128 - defp sym_algo_to_crypto_cipher(algo), do: raise(@v06x_note <> "\n Got: #{inspect(algo)}") - @checksum_size 2 * 8 defp validate_checksum!("" <> _ = plaintext, algo) do {data, chsum1, chsum2, prefix} = trim_checksum(plaintext, algo) @@ -241,8 +206,22 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do {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 - defp build_checksum(algo) do + @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)) @@ -252,8 +231,15 @@ defmodule OpenPGP.IntegrityProtectedDataPacket do <> end + @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() - defp build_null_iv(algo) do + def build_null_iv(algo) do size_bits = Util.sym_algo_cipher_block_size(algo) for(_ <- 1..size_bits, into: <<>>, do: <<0::1>>) end 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 5125d14..9a7776d 100644 --- a/lib/open_pgp/public_key_encrypted_session_key_packet.ex +++ b/lib/open_pgp/public_key_encrypted_session_key_packet.ex @@ -165,61 +165,4 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacket do raise(msg) end end - - @doc """ - Encrypt session key with a given public key. - Require session key algo as it will be encoded and encrypted as well. - Require public key algo as it will be used to generate public key material. - Return ciphertext binary (encrypted session key), which consist of algorithm specific encrypted MPIs. - """ - @spec encrypt(session_key, session_key_algo, public_key_material, public_key_algo) :: ciphertext - when session_key: binary(), - session_key_algo: Util.sym_algo_tuple(), - public_key_material: tuple(), - public_key_algo: Util.public_key_algo_tuple(), - ciphertext: binary() - def encrypt("" <> _ = session_key, session_key_algo, public_key_material, public_key_algo) do - material = build_material(session_key, session_key_algo, public_key_material, public_key_algo) - - for el <- Tuple.to_list(material), reduce: "" do - acc -> acc <> Util.encode_mpi(el) - end - end - - # {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} - @spec build_material(session_key, session_key_algo, public_key_material, public_key_algo) :: pkesk_material - when session_key: binary(), - session_key_algo: Util.sym_algo_tuple(), - public_key_material: tuple(), - public_key_algo: Util.public_key_algo_tuple(), - pkesk_material: tuple() - 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 - - 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/util.ex b/lib/open_pgp/util.ex index eb5c9a4..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: @@ -262,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/test/open_pgp/integrity_protected_data_packet_test.exs b/test/open_pgp/integrity_protected_data_packet_test.exs index 7ee0828..5f63f5c 100644 --- a/test/open_pgp/integrity_protected_data_packet_test.exs +++ b/test/open_pgp/integrity_protected_data_packet_test.exs @@ -3,14 +3,45 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do doctest OpenPGP.IntegrityProtectedDataPacket doctest OpenPGP.Encode.impl_for!(%OpenPGP.IntegrityProtectedDataPacket{}) + alias OpenPGP.Encrypt alias OpenPGP.IntegrityProtectedDataPacket, as: IPDPacket alias OpenPGP.PublicKeyEncryptedSessionKeyPacket, as: PKESK - describe ".encrypt/2,3" do + 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 ciphertext = IPDPacket.encrypt("Hello!", sym_key, @algo, use_mdc: true) + + 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( @@ -23,7 +54,13 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do @algo {8, "AES with 192-bit key"} test "encrypt plaintext with AES-192" do sym_key = :crypto.strong_rand_bytes(24) - assert ciphertext = IPDPacket.encrypt("Hello!", sym_key, @algo, use_mdc: true) + + 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( @@ -36,7 +73,13 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do @algo {9, "AES with 256-bit key"} test "encrypt plaintext with AES-256" do sym_key = :crypto.strong_rand_bytes(32) - assert ciphertext = IPDPacket.encrypt("Hello!", sym_key, @algo, use_mdc: true) + + 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( @@ -50,7 +93,12 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do test "encrypt plaintext with AES-128 and no MDC" do sym_key = :crypto.strong_rand_bytes(16) - assert ciphertext = IPDPacket.encrypt("Hello!", sym_key, @algo, use_mdc: false) + 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( @@ -63,7 +111,13 @@ defmodule OpenPGP.IntegrityProtectedDataPacketTest do @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 ciphertext = IPDPacket.encrypt("Hello!", sym_key, @algo, use_mdc: false) + + 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( 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 382625c..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 @@ -4,9 +4,11 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do 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 @@ -79,7 +81,7 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do end end - describe ".encrypt/2" do + describe "OpenPGP.Encrypt.encrypt/2" do # [RFC3526](https://datatracker.ietf.org/doc/html/rfc3526) @modp_group_1536 """ FFFFFFFF FFFFFFFF C90FDAA2 2168C234 C4C6628B 80DC1CD1 @@ -105,16 +107,20 @@ defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do private_key = :crypto.strong_rand_bytes(128) a = :binary.decode_unsigned(private_key) - # Generate the public key (g**private_key mod p) + # Generate the public key exp (g**private_key mod p) e = :crypto.mod_pow(g, a, p) - public_key_material = {@prime_p, @group_g, e} - public_key_algo = {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"} + recipient_public_key = %PublicKeyPacket{ + algo: {16, "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]"}, + material: {@prime_p, @group_g, e} + } - session_key_algo = {9, "AES with 256-bit key"} - session_key = "12345678901234567890123456789012" + pkesk_packet = %PKESK{ + session_key_algo: {9, "AES with 256-bit key"}, + session_key_material: {"12345678901234567890123456789012"} + } - assert ciphertext = PKESK.encrypt(session_key, session_key_algo, public_key_material, public_key_algo) + assert %PKESK{ciphertext: ciphertext} = Encrypt.encrypt(pkesk_packet, recipient_public_key: recipient_public_key) # Decrypt Elgamal assert {c1, next} = Util.decode_mpi(ciphertext)