diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8aee49d..ee49cb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: include: - elixir: "1.18" otp: "27" + latest: true - elixir: "1.16" otp: "26" - elixir: "1.15" @@ -54,6 +55,7 @@ jobs: run: mix deps.get - name: Check Formatting + if: matrix.latest run: mix format --check-formatted - name: Run unit tests diff --git a/CHANGELOG.md b/CHANGELOG.md index e3fa923..150aaef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v0.6.4 + +### Enhancements + +* Implementation of User ID Packet (Tag 13) - `OpenPGP.UserIdPacket` +* Add `OpenPGP.Encode` protocol implementation for: + * `OpenPGP.UserIdPacket` + * `OpenPGP.PublicKeyPacket` +* Better error message for: + * `OpenPGP.cast_packet/1` + ## v0.6.3 ### Enhancements diff --git a/lib/open_pgp.ex b/lib/open_pgp.ex index 02c8341..9867f94 100644 --- a/lib/open_pgp.ex +++ b/lib/open_pgp.ex @@ -84,6 +84,7 @@ defmodule OpenPGP do | %OpenPGP.PublicKeyEncryptedSessionKeyPacket{} | %OpenPGP.SecretKeyPacket{} | %OpenPGP.PublicKeyPacket{} + | %OpenPGP.UserIdPacket{} | %OpenPGP.CompressedDataPacket{} | %OpenPGP.IntegrityProtectedDataPacket{} | %OpenPGP.LiteralDataPacket{} @@ -125,6 +126,7 @@ defmodule OpenPGP do 7 => OpenPGP.SecretKeyPacket, 8 => OpenPGP.CompressedDataPacket, 11 => OpenPGP.LiteralDataPacket, + 13 => OpenPGP.UserIdPacket, 14 => OpenPGP.PublicKeyPacket, 18 => OpenPGP.IntegrityProtectedDataPacket } @@ -141,7 +143,16 @@ defmodule OpenPGP do case packet.tag do %PacketTag{tag: {tag_id, _}} when tag_id in @tag_to_packet_ids -> impl = Map.get(@tag_to_packet, tag_id) - {casted, <<>>} = packet |> Util.concat_body() |> impl.decode() + {casted, rest} = packet |> Util.concat_body() |> impl.decode() + rsize = byte_size(rest) + + if rsize > 0 do + err = + "#{inspect(impl)}.decode/1 decoded #{rsize} unexpected trailing byte(s): #{inspect(rest, printable_limit: 20)}" + + raise(err) + end + casted _ -> diff --git a/lib/open_pgp/encode/public_key_packet_impl.ex b/lib/open_pgp/encode/public_key_packet_impl.ex new file mode 100644 index 0000000..ed8df31 --- /dev/null +++ b/lib/open_pgp/encode/public_key_packet_impl.ex @@ -0,0 +1,34 @@ +defimpl OpenPGP.Encode, for: OpenPGP.PublicKeyPacket do + alias OpenPGP.PublicKeyPacket + alias OpenPGP.Util + + def tag(_), do: {6, "Public-Key Packet"} + + @doc """ + Encode Public-Key Packet. + Return encoded packet body (version 4 only). + + ### Example: + + iex> packet = %OpenPGP.PublicKeyPacket{ + ...> version: 4, + ...> created_at: ~U[2024-01-02 18:03:04Z], + ...> algo: {1, "RSA (Encrypt or Sign) [HAC]"}, + ...> material: {<<0x01>>, <<0x01>>} + ...> } + iex> OpenPGP.Encode.encode(packet) + <<4::8, 1704218584::32, 1::8, 0, 1, 1, 0, 1, 1>> + + """ + def encode(%PublicKeyPacket{version: 4} = packet, _opts) do + {algo_id, _} = packet.algo + ts = DateTime.to_unix(packet.created_at) + + encoded_material = + for mpi <- Tuple.to_list(packet.material), reduce: "" do + acc -> acc <> Util.encode_mpi(mpi) + end + + <<4::8, ts::32, algo_id::8, encoded_material::binary>> + end +end diff --git a/lib/open_pgp/encode/user_id_packet_impl.ex b/lib/open_pgp/encode/user_id_packet_impl.ex new file mode 100644 index 0000000..2973223 --- /dev/null +++ b/lib/open_pgp/encode/user_id_packet_impl.ex @@ -0,0 +1,15 @@ +defimpl OpenPGP.Encode, for: OpenPGP.UserIdPacket do + def tag(_), do: {13, "User ID Packet"} + + @doc """ + Encode User ID Packet. + Return encoded packet body (the UTF-8 User ID string). + + ### Example: + + iex> OpenPGP.Encode.encode(%OpenPGP.UserIdPacket{id: "Alice "}) + "Alice " + + """ + def encode(%OpenPGP.UserIdPacket{id: id}, _opts), do: id +end diff --git a/lib/open_pgp/secret_key_packet.ex b/lib/open_pgp/secret_key_packet.ex index d4b154e..e3d995e 100644 --- a/lib/open_pgp/secret_key_packet.ex +++ b/lib/open_pgp/secret_key_packet.ex @@ -108,8 +108,6 @@ defmodule OpenPGP.SecretKeyPacket do @behaviour OpenPGP.Packet.Behaviour - alias OpenPGP.Util - defstruct [ :public_key, :s2k_usage, diff --git a/lib/open_pgp/user_id_packet.ex b/lib/open_pgp/user_id_packet.ex new file mode 100644 index 0000000..8f368c6 --- /dev/null +++ b/lib/open_pgp/user_id_packet.ex @@ -0,0 +1,36 @@ +defmodule OpenPGP.UserIdPacket do + @moduledoc """ + Represents structured data for User ID Packet (Tag 13). + + ### Example: + + iex> OpenPGP.UserIdPacket.decode("Alice ") + {%OpenPGP.UserIdPacket{id: "Alice "}, <<>>} + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 5.11. User ID Packet (Tag 13) + + A User ID packet consists of UTF-8 text that is intended to represent + the name and email address of the key holder. By convention, it + includes an RFC 2822 mail name-addr, but there are no restrictions on + its content. The packet length in the header specifies the length of + the User ID. + """ + + @behaviour OpenPGP.Packet.Behaviour + + defstruct [:id] + + @type t :: %__MODULE__{id: binary()} + + @doc """ + Decode User ID Packet given input binary. + Return structured packet and remaining binary (empty binary). + """ + @impl OpenPGP.Packet.Behaviour + @spec decode(binary()) :: {t(), <<>>} + def decode("" <> _ = input), do: {%__MODULE__{id: input}, ""} +end diff --git a/mix.exs b/mix.exs index 4320bab..1f028c5 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule OpenPGP.MixProject do use Mix.Project @source_url "https://github.com/DivvyPayHQ/open_pgp" - @version "0.6.3" + @version "0.6.4" @description "OpenPGP Message Format in Elixir - RFC4880" def project() do diff --git a/test/open_pgp/public_key_packet_test.exs b/test/open_pgp/public_key_packet_test.exs index e9a5390..80cd9ae 100644 --- a/test/open_pgp/public_key_packet_test.exs +++ b/test/open_pgp/public_key_packet_test.exs @@ -1,5 +1,8 @@ defmodule OpenPGP.PublicKeyPacketTest do use OpenPGP.Test.Case, async: true + + doctest OpenPGP.Encode.impl_for!(%OpenPGP.PublicKeyPacket{}) + alias OpenPGP.Packet alias OpenPGP.Packet.PacketTag alias OpenPGP.PublicKeyPacket @@ -70,4 +73,37 @@ defmodule OpenPGP.PublicKeyPacketTest do "2CAC73CCC53706BE856E26D02D0A7F1AB8122614E91000F5EB1B240F50C66D9048F861D3" == Base.encode16(value_y) end + + describe "OpenPGP.Encode.encode/1,2" do + test "encodes RSA public key to original bytes" do + assert [%Packet{body: chunks, tag: %PacketTag{tag: {5, "Secret-Key Packet"}}} | _] = + OpenPGP.list_packets(@rsa2048_priv) + + # The Secret-Key packet contain all the data of the Public-Key packet, + # with additional algorithm-specific secret-key data appended. + # Here we decode a Secret-Key packet with the Publick-Key decoder, + # which will return the PublicKeyPacket struct and the remaining + # algorithm-specific secret-key data as a binary. + data = OpenPGP.Util.concat_body(chunks) + {pk_decoded, rest} = PublicKeyPacket.decode(data) + + pk_raw_length = byte_size(data) - byte_size(rest) + <> = data + + assert OpenPGP.Encode.encode(pk_decoded) == pk_raw + end + + test "encodes ElGamal public key to original bytes" do + assert %Packet{body: chunks, tag: %PacketTag{tag: {14, "Public-Subkey Packet"}}} = + @elg2048_pub |> OpenPGP.list_packets() |> Enum.at(3) + + data = OpenPGP.Util.concat_body(chunks) + {pk_decoded, <<>>} = PublicKeyPacket.decode(data) + assert OpenPGP.Encode.encode(pk_decoded) == data + end + + test "tag/1 returns {6, Public-Key Packet}" do + assert {6, "Public-Key Packet"} = OpenPGP.Encode.tag(%PublicKeyPacket{}) + end + end end diff --git a/test/open_pgp/radix64_test.exs b/test/open_pgp/radix64_test.exs index a823286..c9abddc 100644 --- a/test/open_pgp/radix64_test.exs +++ b/test/open_pgp/radix64_test.exs @@ -67,7 +67,7 @@ defmodule OpenPGP.Radix64Test do version: 4 } }, - %Packet{tag: %PacketTag{tag: {13, "User ID Packet"}}}, + %OpenPGP.UserIdPacket{}, %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}}, %OpenPGP.SecretKeyPacket{ public_key: %PublicKeyPacket{ diff --git a/test/open_pgp/user_id_packet_test.exs b/test/open_pgp/user_id_packet_test.exs new file mode 100644 index 0000000..cadd204 --- /dev/null +++ b/test/open_pgp/user_id_packet_test.exs @@ -0,0 +1,32 @@ +defmodule OpenPGP.UserIdPacketTest do + use OpenPGP.Test.Case, async: true + doctest OpenPGP.UserIdPacket + doctest OpenPGP.Encode.impl_for!(%OpenPGP.UserIdPacket{}) + + alias OpenPGP.Encode + alias OpenPGP.UserIdPacket + + test "decode/1 returns the input as id with empty remainder" do + assert {%UserIdPacket{id: "John Doe "}, <<>>} = + UserIdPacket.decode("John Doe ") + end + + test "cast_packets casts Tag 13 to UserIdPacket" do + message = File.read!("test/fixtures/rsa2048-priv.pgp") + [_sk, uid_pkt | _] = OpenPGP.list_packets(message) + + assert [%UserIdPacket{id: "John Doe (RSA2048) "}] = OpenPGP.cast_packets([uid_pkt]) + end + + describe "OpenPGP.Encode.encode/1,2" do + test "encode/2 returns the id binary" do + packet = %UserIdPacket{id: "Alice "} + assert Encode.encode(packet) == "Alice " + end + + test "tag/1 returns packet tag 13" do + packet = %UserIdPacket{id: "Alice "} + assert {13, "User ID Packet"} == Encode.tag(packet) + end + end +end diff --git a/test/open_pgp_test.exs b/test/open_pgp_test.exs index 70bbb1f..855ffed 100644 --- a/test/open_pgp_test.exs +++ b/test/open_pgp_test.exs @@ -157,10 +157,30 @@ defmodule OpenPGPTest do end end + @expected_error "OpenPGP.PublicKeyPacket.decode/1 decoded 697 unexpected trailing byte(s): " <> + "<<254, 7, 3, 2, 248, 49, 205, 223, 27, 66, 166, 109, 252, 54, 235, 29, 19, 66, " <> + "217, 249, 233, 73, 143, 69, 142, 10, 18, 42, 106, 122, 114, 71, 167, 145, 111, " <> + "190, 206, 40, 102, 166, 213, 241, 148, 116, 163, 167, 163, 228, 5, 223, ...>>" + test "cast_packet/1 raises on trailing bytes from decoder" do + # PublicKeyPacket.decode/1 stops after reading the key material MPIs, + # so a Tag 6 packet body with extra trailing bytes produces a remainder. + # The secret key body starts with the public key fields followed by secret key data. + # Casting it as a Tag 6 (Public-Key) packet will leave the secret key bytes as remainder. + + [%Packet{body: chunks, tag: %PacketTag{tag: {5, "Secret-Key Packet"}}} | _] = OpenPGP.list_packets(@rsa2048_priv) + + ptag = %PacketTag{format: :new, tag: {6, "Public-Key Packet"}} + packet = %Packet{tag: ptag, body: chunks} + + assert_raise RuntimeError, @expected_error, fn -> + OpenPGP.cast_packet(packet) + end + end + test "decode secret key message" do assert [ %SecretKeyPacket{}, - %Packet{tag: %PacketTag{tag: {13, "User ID Packet"}}}, + %OpenPGP.UserIdPacket{}, %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}}, %SecretKeyPacket{}, %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}} @@ -194,7 +214,7 @@ defmodule OpenPGPTest do assert keyring = [ %SecretKeyPacket{}, - %Packet{tag: %PacketTag{tag: {13, "User ID Packet"}}}, + %OpenPGP.UserIdPacket{}, %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}}, %SecretKeyPacket{}, %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}}