diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/.dialyzer_ignore.exs @@ -0,0 +1 @@ +[] diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..8c596cf --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,6 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"], + line_length: 120, + rename_deprecated_at: "1.7.0" +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..781b272 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +open_pgp-*.tar + +# Temporary files, for example, from tests. +/tmp/ diff --git a/.sobelow-conf b/.sobelow-conf new file mode 100644 index 0000000..b95eb4c --- /dev/null +++ b/.sobelow-conf @@ -0,0 +1,11 @@ +[ + verbose: false, + private: false, + compact: true, + skip: true, + exit: "low", + format: "txt", + threshold: "low", + ignore: [], + ignore_files: [] +] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f6c1694 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ +# Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at [INSERT CONTACT METHOD]. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, +available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..432781c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,23 @@ +# License + +MIT License + +Copyright (c) 2024 BILL Operations, LLC. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea22d9c --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# OpenPGP + +OpenPGP lib allows to inspect, decode and decrypt OpenPGP Message Format as per [RFC4880](https://www.ietf.org/rfc/rfc4880.html) + +## Installation + +Add `:open_pgp` to the list of dependencies in `mix.exs`: + +```elixir +def deps() do + [ + {:open_pgp, "~> 0.5"} + ] +end +``` + +## OpenPGP Packet + +The `OpenPGP.Packet` is a generic packet type. It has an essential purpose: split OpenPGP message in packets and decode packet tags. + +An OpenPGP message is constructed from a number of records that are traditionally called packets. A packet is a chunk of data that has a tag specifying its meaning. An OpenPGP message, keyring, certificate, and so forth consists of a number of packets. Some of those packets may contain other OpenPGP packets (for example, a compressed data packet, when uncompressed, contains OpenPGP packets). Each packet consists of a packet header, followed by the packet body. For more details refer to [Packet Syntax chapter in RFC4880](https://www.ietf.org/rfc/rfc4880.html#section-4) + +Once OpenPGP message split into generic packets, the higher order tag-specific packet decoders can be applied on its' data. Example: + +``` +{packet, _rest} = OpenPGP.Packet.decode("...") + +{compressed_data_packet, <<>>} = + packet |> OpenPGP.Util.concat_body() |> OpenPGP.CompressedDataPacket.decode() +``` + +More details can be found in `OpenPGP.Packet` and `OpenPGP.Packet.Behaviour` + +## Examples + +### List and cast packets + +List packets in a message and then cast to specific packet types. + +``` +iex> message = <<160, 24, 2, 120, 156, 243, 72, 205, 201, 201, 215, 81, 8, 207, 47, 202, 73, 81, 84, 84, 4, 0, 40, 213, 4, 172>> +...> +iex> packets = OpenPGP.list_packets(message) +[ + %OpenPGP.Packet{ + body: [ + %OpenPGP.Packet.BodyChunk{ + chunk_length: {:fixed, 24}, + data: <<2, 120, 156, 243, 72, 205, 201, 201, 215, 81, 8, 207, 47, 202, 73, 81, 84, 84, 4, 0, 40, 213, 4, 172>>, + header_length: 1 + } + ], + tag: %OpenPGP.Packet.PacketTag{ + format: :old, + length_type: {0, "one-octet"}, + tag: {8, "Compressed Data Packet"} + } + } +] +iex> OpenPGP.cast_packets(packets) +[ + %OpenPGP.CompressedDataPacket{ + algo: {2, "ZLIB [RFC1950]"}, + data_deflated: <<120, 156, 243, 72, 205, 201, 201, 215, 81, 8, 207, 47, 202, 73, 81, 84, 84, 4, 0, 40, 213, 4, 172>>, + data_inflated: "Hello, World!!!" + } +] +``` + +### Decode Generic OpenPGP packet + +In this example the packet tag specifies a Signature Packet with body length of 7 bytes. The remaining binary will be return as a second element in a two element tuple. More details in `OpenPGP.Packet.Behaviour`. + +``` +iex> alias OpenPGP.Packet +iex> alias OpenPGP.Packet.PacketTag +iex> alias OpenPGP.Packet.BodyChunk +iex> Packet.decode(<<1::1, 0::1, 2::4, 0::2, 7::8, "Hello, World!!!">>) +{ + %Packet{ + tag: %PacketTag{format: :old, length_type: {0, "one-octet"}, tag: {2, "Signature Packet"}}, + body: [%BodyChunk{chunk_length: {:fixed, 7}, data: "Hello, ", header_length: 1}] + }, + "World!!!" +} +``` + +### CompressedDataPacket + +The `OpenPGP.CompressedDataPacket` will inflate data implicitly when decoded (also, data inflated implicitly when `OpenPGP.cast_packets/1` used). + +``` +iex> alias OpenPGP.CompressedDataPacket +iex> deflated = <<120, 156, 243, 72, 205, 201, 201, 215, 81, 8, 207, 47, 202, 73, 81, 84, 84, 4, 0, 40, 213, 4, 172>> +iex> CompressedDataPacket.decode(<<2, deflated::binary>>) +{ + %CompressedDataPacket{ + algo: {2, "ZLIB [RFC1950]"}, + data_deflated: deflated, + data_inflated: "Hello, World!!!"}, + <<>> +} +``` + +### IntegrityProtectedDataPacket + +The `OpenPGP.IntegrityProtectedDataPacket` does not decrypt its' data implicitly. The `OpenPGP.IntegrityProtectedDataPacket.decrypt/2` should be used to get plaintext. Please note that some packets have packet speicifc functions, such as `OpenPGP.IntegrityProtectedDataPacket.decrypt/2`. + +``` +iex> alias OpenPGP.IntegrityProtectedDataPacket +iex> alias OpenPGP.PublicKeyEncryptedSessionKeyPacket +...> +iex> key = <<38, 165, 130, 172, 168, 51, 184, 238, 96, 204, 88, +...> 134, 93, 25, 162, 22, 83, 211, 140, 176, 115, 113, 37, 201, +...> 171, 249, 115, 64, 94, 59, 35, 60>> +...> +iex> iv = <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>> +iex> <> = :crypto.strong_rand_bytes(16) +iex> plaintext = <> +...> +iex> ciphertext = +...> :crypto.crypto_one_time( +...> :aes_256_cfb128, +...> key, +...> iv, +...> plaintext, +...> true) +...> +iex> payload = <<1::8, ciphertext::binary>> +iex> {packet_decoded, <<>>} = IntegrityProtectedDataPacket.decode(payload) +{ + %IntegrityProtectedDataPacket{ + ciphertext: ciphertext, + plaintext: nil, + version: 1 + }, + <<>> +} +iex> pkesk = %PublicKeyEncryptedSessionKeyPacket{ +...> version: 3, +...> session_key_algo: {9, "AES with 256-bit key"}, +...> session_key_material: {key} +...> } +...> +iex> IntegrityProtectedDataPacket.decrypt(packet_decoded, pkesk) +%IntegrityProtectedDataPacket{ + version: 1, + plaintext: "Hello", + ciphertext: ciphertext +} +``` + +## Notes + +As of **v0.5.x**: + +1. Any valid OpenPGP message can be decoded via generic `OpenPGP.Packet` decoder. This abstraction layer provide Packet Tags and Body Chunks for packet envelope level evaluation. +1. Some Packet Tag specific decoders implemented with limited feature support: + 1. `OpenPGP.LiteralDataPacket` + 1. `OpenPGP.PublicKeyEncryptedSessionKeyPacket` + 1. `OpenPGP.PublicKeyPacket` - support only V4 packets + 1. `OpenPGP.SecretKeyPacket` - support only V4 packets; Iterated and Salted String-to-Key (S2K) specifier (ID: 3); S2K usage convention octet of 254 only; S2K hashing algo SHA1; AES128 symmetric encryption of secret key material + 1. `OpenPGP.CompressedDataPacket` - support only ZLIB- and ZIP-style blocks + 1. `OpenPGP.IntegrityProtectedDataPacket` - support Session Key algo 9 (AES with 256-bit key) in CFB mode; Modification Detection Code system is not supported + +At a high level `OpenPGP.list_packets/1` and `OpenPGP.cast_packets/1` serve as an entrypoint to OpenPGP Message decoding and extracting generic data. + +Packet specific decoders implement `OpenPGP.Packet.Behaviour`, which exposes `.decode/1` interface (including genric `OpenPGP.Packet`). Additionaly some of the packet specific decoders may provide interface for further packet processing, such as `OpenPGP.SecretKeyPacket.decrypt/2`. + +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"** + +## Refs, Snippets, Misc + +```console +# GPG commands +~$ gpg --list-keys +~$ gpg --list-secret-keys +~$ gpg --export-secret-key --armor john.doe@example.com > ./private.pgp +~$ gpg --list-packets --verbose example.txt.pgp +~$ gpg --encrypt --recipient F89B64F782254B03624FCF5C052E8381B5C335DA /usr/share/dict/words +~$ gpg --batch --passphrase "passphrase" --quick-generate-key "John Doe (RSA2048) " rsa2048 default never +~$ gpg --edit-key F89B64F782254B03624FCF5C052E8381B5C335DA + +# Handy tools +~$ hexdump -vx ./words.pgp +~$ xxd -b ./words.pgp +~$ xxd -g 1 +``` diff --git a/lib/open_pgp.ex b/lib/open_pgp.ex new file mode 100644 index 0000000..091d87b --- /dev/null +++ b/lib/open_pgp.ex @@ -0,0 +1,148 @@ +defmodule OpenPGP do + @v05x_note """ + As of 0.5.x subset of RFC4880 Packet Tags can be casted. Other Packet + tags remain as %Packet{} (not casted). Should not be considered as + error. + """ + + @moduledoc """ + OpenPGP lib allows to inspect, decode and decrypt OpenPGP Message + Format as per [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + As of v0.5.x: + + 1. Any valid OpenPGP message can be decoded via generic + `OpenPGP.Packet` decoder. This abstraction layer provide Packet + Tags and Body Chunks for packet envelope level evaluation. + 1. Some Packet Tag specific decoders implemented with limited + feature support: + 1. `OpenPGP.LiteralDataPacket` + 1. `OpenPGP.PublicKeyEncryptedSessionKeyPacket` + 1. `OpenPGP.PublicKeyPacket` - support only V4 packets + 1. `OpenPGP.SecretKeyPacket` - support only V4 packets; Iterated + and Salted String-to-Key (S2K) specifier (ID: 3); S2K usage + convention octet of 254 only; S2K hashing algo SHA1; AES128 + symmetric encryption of secret key material + 1. `OpenPGP.CompressedDataPacket` - support only ZLIB- and ZIP- + style blocks + 1. `OpenPGP.IntegrityProtectedDataPacket` - support Session Key + algo 9 (AES with 256-bit key) in CFB mode; Modification + Detection Code system is not supported + + At a high level `OpenPGP.list_packets/1` and `OpenPGP.cast_packets/1` + serve as an entrypoint to OpenPGP Message decoding and extracting + generic data. Packet specific decoders implement + `OpenPGP.Packet.Behaviour`, which exposes `.decode/1` interface + (including genric `OpenPGP.Packet`). Additionaly some of the packet + specific decoders may provide interface for further packet processing, + such as `OpenPGP.SecretKeyPacket.decrypt/2`. + + ### Examples: + + Decode message packets and then cast + + iex> message = <<160, 24, 2, 120, 156, 243, 72, 205, 201, 201, 215, 81, 8, 207, 47, 202, 73, + ...> 81, 84, 84, 4, 0, 40, 213, 4, 172>> + ...> + iex> packets = OpenPGP.list_packets(message) + [ + %OpenPGP.Packet{ + body: [ + %OpenPGP.Packet.BodyChunk{ + chunk_length: {:fixed, 24}, + data: <<2, 120, 156, 243, 72, 205, 201, 201, 215, 81, 8, 207, 47, 202, 73, 81, 84, + 84, 4, 0, 40, 213, 4, 172>>, + header_length: 1 + } + ], + tag: %OpenPGP.Packet.PacketTag{ + format: :old, + length_type: {0, "one-octet"}, + tag: {8, "Compressed Data Packet"} + } + } + ] + iex> OpenPGP.cast_packets(packets) + [ + %OpenPGP.CompressedDataPacket{ + algo: {2, "ZLIB [RFC1950]"}, + data_deflated: <<120, 156, 243, 72, 205, 201, 201, 215, 81, 8, 207, 47, 202, 73, 81, 84, + 84, 4, 0, 40, 213, 4, 172>>, + data_inflated: "Hello, World!!!" + } + ] + """ + + 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() + + @doc """ + Decode all packets in a message (input). + Return a list of %Packet{} structs. Does not cast packets. To cast + generic packets, use `.cast_packets/1` after `.list_packets/1`, i.e. + <<...>> |> OpenPGP.list_packets() |> OpenPGP.cast_packets() + + This function extremely handy for inspection, when operating at PTag + and BodyChunk level. + """ + @spec list_packets(binary()) :: [Packet.t()] + def list_packets("" <> _ = input), do: do_list_packets(input, []) + + @spec do_list_packets(input :: binary(), acc :: [Packet.t()]) :: [Packet.t()] + defp do_list_packets("", acc), do: Enum.reverse(acc) + + defp do_list_packets("" <> _ = input, acc) do + {packet, next} = Packet.decode(input) + do_list_packets(next, [packet | acc]) + end + + @doc """ + Similar to `.cast_packet/1`, but operates on a list of generic + packets. + + > NOTE: #{@v05x_note} + """ + @spec cast_packets([Packet.t()]) :: [any_packet()] + def cast_packets(packets) when is_list(packets), do: Enum.map(packets, &cast_packet/1) + + @tag_to_packet %{ + 1 => OpenPGP.PublicKeyEncryptedSessionKeyPacket, + 5 => OpenPGP.SecretKeyPacket, + 6 => OpenPGP.PublicKeyPacket, + 7 => OpenPGP.SecretKeyPacket, + 8 => OpenPGP.CompressedDataPacket, + 11 => OpenPGP.LiteralDataPacket, + 14 => OpenPGP.PublicKeyPacket, + 18 => OpenPGP.IntegrityProtectedDataPacket + } + @tag_to_packet_ids Map.keys(@tag_to_packet) + + @doc """ + Cast a generic packet %Packet{} to a speicific struct with a packet + specific data assigned. + + > NOTE: #{@v05x_note} + """ + @spec cast_packet(Packet.t()) :: any_packet() + def cast_packet(%Packet{} = packet) 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 + + _ -> + packet + end + end +end diff --git a/lib/open_pgp/compressed_data_packet.ex b/lib/open_pgp/compressed_data_packet.ex new file mode 100644 index 0000000..4407e0d --- /dev/null +++ b/lib/open_pgp/compressed_data_packet.ex @@ -0,0 +1,120 @@ +defmodule OpenPGP.CompressedDataPacket do + @v05x_note """ + As of 0.5.x Compressed Data Packet supports only: + + 1. ZIP-style blocks (ID: 1) + 1. ZLIB-style blocks (ID: 2) + """ + + @moduledoc """ + Represents structured data for Compressed Data Packet. + + ### Example: + + iex> alias OpenPGP.CompressedDataPacket + iex> deflated = <<120, 156, 243, 72, 205, 201, 201, 215, 81, 8, + ...> 207, 47, 202, 73, 81, 84, 84, 4, 0, 40, 213, 4, 172>> + iex> CompressedDataPacket.decode(<<2, deflated::binary>>) + { + %CompressedDataPacket{ + algo: {2, "ZLIB [RFC1950]"}, + data_deflated: deflated, + data_inflated: "Hello, World!!!"}, + <<>> + } + + > NOTE: #{@v05x_note} + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 5.6. Compressed Data Packet (Tag 8) + + The Compressed Data packet contains compressed data. Typically, this + packet is found as the contents of an encrypted packet, or following + a Signature or One-Pass Signature packet, and contains a literal data + packet. + + The body of this packet consists of: + + - One octet that gives the algorithm used to compress the packet. + + - Compressed data, which makes up the remainder of the packet. + + A Compressed Data Packet's body contains an block that compresses + some set of packets. See section "Packet Composition" for details on + how messages are formed. + + ZIP-compressed packets are compressed with raw RFC 1951 [RFC1951] + DEFLATE blocks. Note that PGP V2.6 uses 13 bits of compression. If + an implementation uses more bits of compression, PGP V2.6 cannot + decompress it. + + ZLIB-compressed packets are compressed with RFC 1950 [RFC1950] ZLIB- + style blocks. + + BZip2-compressed packets are compressed using the BZip2 [BZ2] + algorithm. + """ + + @behaviour OpenPGP.Packet.Behaviour + + alias OpenPGP.Util + + defstruct [:algo, :data_deflated, :data_inflated] + + @type t :: %__MODULE__{ + algo: Util.compression_algo_tuple(), + data_deflated: bitstring(), + data_inflated: binary() + } + + @doc """ + Decode Compressed Data Packet given input binary. + Return structured packet and remaining binary (empty binary). + """ + @impl OpenPGP.Packet.Behaviour + @spec decode(binary()) :: {t(), <<>>} + def decode(<>) do + window_bits = + case algo do + 1 -> -15 + 2 -> 15 + other -> raise("Unsupported compression algo #{inspect(Util.compression_algo_tuple(other))}. " <> @v05x_note) + end + + inflated = inflate(deflated, window_bits) + + packet = %__MODULE__{ + algo: Util.compression_algo_tuple(algo), + data_deflated: deflated, + data_inflated: inflated + } + + {packet, <<>>} + end + + # A negative WindowBits value makes zlib ignore the zlib header + # (and checksum) from the stream. Notice that the zlib source mentions + # this only as a undocumented feature. + @max_chunks 1024 + @spec inflate(binary(), window_bits :: integer()) :: binary() + defp inflate(deflated, window_bits) do + z = :zlib.open() + + try do + :zlib.inflateInit(z, window_bits) + + Enum.reduce_while(1..@max_chunks, <<>>, fn _, acc -> + case :zlib.safeInflate(z, deflated) do + {:continue, [chunk]} -> {:cont, acc <> chunk} + {:finished, [chunk]} -> {:halt, acc <> chunk} + {:finished, []} -> {:halt, acc} + end + end) + after + :zlib.close(z) + end + end +end diff --git a/lib/open_pgp/integrity_protected_data_packet.ex b/lib/open_pgp/integrity_protected_data_packet.ex new file mode 100644 index 0000000..6418ba6 --- /dev/null +++ b/lib/open_pgp/integrity_protected_data_packet.ex @@ -0,0 +1,199 @@ +defmodule OpenPGP.IntegrityProtectedDataPacket do + @v05x_note """ + As of 0.5.x Symmetrically Encrypted Integrity Protected Data Packet: + + 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. + + ### Example: + + iex> alias OpenPGP.IntegrityProtectedDataPacket + iex> alias OpenPGP.PublicKeyEncryptedSessionKeyPacket + ...> + iex> key = <<38, 165, 130, 172, 168, 51, 184, 238, 96, 204, 88, + ...> 134, 93, 25, 162, 22, 83, 211, 140, 176, 115, 113, 37, 201, + ...> 171, 249, 115, 64, 94, 59, 35, 60>> + ...> + iex> iv = <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>> + iex> <> = :crypto.strong_rand_bytes(16) + iex> plaintext = <> + ...> + iex> ciphertext = + ...> :crypto.crypto_one_time( + ...> :aes_256_cfb128, + ...> key, + ...> iv, + ...> plaintext, + ...> true) + ...> + iex> payload = <<1::8, ciphertext::binary>> + iex> {packet_decoded, <<>>} = + ...> IntegrityProtectedDataPacket.decode(payload) + { + %IntegrityProtectedDataPacket{ + ciphertext: ciphertext, + plaintext: nil, + version: 1 + }, + <<>> + } + iex> pkesk = %PublicKeyEncryptedSessionKeyPacket{ + ...> version: 3, + ...> session_key_algo: {9, "AES with 256-bit key"}, + ...> session_key_material: {key} + ...> } + ...> + iex> IntegrityProtectedDataPacket.decrypt(packet_decoded, pkesk) + %IntegrityProtectedDataPacket{ + version: 1, + plaintext: "Hello", + ciphertext: ciphertext + } + + + > NOTE: #{@v05x_note} + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 5.13. Sym. Encrypted Integrity Protected Data Packet (Tag 18) + + The Symmetrically Encrypted Integrity Protected Data packet is a + variant of the Symmetrically Encrypted Data packet. + + ... + + This packet contains data encrypted with a symmetric-key algorithm + and protected against modification by the SHA-1 hash algorithm. When + it has been decrypted, it will typically contain other packets (often + a Literal Data packet or Compressed Data packet). The last decrypted + packet in this packet's payload MUST be a Modification Detection Code + packet. + + The body of this packet consists of: + + - A one-octet version number. The only currently defined value is + 1. + + - Encrypted data, the output of the selected symmetric-key cipher + operating in Cipher Feedback mode with shift amount equal to the + block size of the cipher (CFB-n where n is the block size). + + The symmetric cipher used MUST be specified in a Public-Key or + Symmetric-Key Encrypted Session Key packet that precedes the + Symmetrically Encrypted Data packet. In either case, the cipher + algorithm octet is prefixed to the session key before it is + encrypted. + + The data is encrypted in CFB mode, with a CFB shift size equal to the + cipher's block size. 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. 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. Unlike the Symmetrically Encrypted Data Packet, no + special CFB resynchronization is done after encrypting this prefix + data. See "OpenPGP CFB Mode" below for more details. + + The repetition of 16 bits in the random data prefixed to the message + allows the receiver to immediately check whether the session key is + incorrect. + + ... + + """ + + @behaviour OpenPGP.Packet.Behaviour + + alias OpenPGP.PublicKeyEncryptedSessionKeyPacket, as: PKESK + alias OpenPGP.Util + + defstruct [:version, :ciphertext, :plaintext] + + @type t :: %__MODULE__{ + version: byte(), + ciphertext: binary(), + plaintext: binary() | nil + } + + @doc """ + Decode Sym. Encrypted and Integrity Protected Data Packet given input + binary. + Return structured packet and remaining binary (empty binary). + """ + @impl OpenPGP.Packet.Behaviour + @spec decode(binary()) :: {t(), <<>>} + def decode(<>) when version == 1 do + packet = %__MODULE__{ + version: version, + ciphertext: ciphertext + } + + {packet, <<>>} + end + + @doc """ + Decrypt Sym. Encrypted and Integrity Protected Data Packet + (PKESK-Packet) given decoded PKESK-Packet and a decrypted Public-Key + Encrypted Session Key Packet. + Return PKESK-Packet with `:plaintext` attr assigned. + Raises an error if checksum does not match. + """ + @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)}") + end + + null_iv = build_null_iv(pkesk) + payload = decrypt(:aes_256_cfb128, session_key, null_iv, ciphertext) + {data, _chsum} = validate_checksum!(payload, pkesk) + + %{packet | plaintext: data} + end + + @checksum_octets 2 + defp validate_checksum!("" <> _ = plaintext, %PKESK{session_key_algo: sk_algo}) do + cipher_block_octets = sk_algo |> Util.sym_algo_cipher_block_size() |> Kernel.div(8) + prefix_byte_size = cipher_block_octets - @checksum_octets + + << + _::bytes-size(prefix_byte_size), + chsum1::bytes-size(@checksum_octets), + chsum2::bytes-size(@checksum_octets), + data::binary + >> = plaintext + + if chsum1 == chsum2 do + {data, chsum1} + else + 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}." + + 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>>) + end + + defp decrypt(cipher, key, iv, ciphertext), + do: :crypto.crypto_one_time(cipher, key, iv, ciphertext, false) +end diff --git a/lib/open_pgp/literal_data_packet.ex b/lib/open_pgp/literal_data_packet.ex new file mode 100644 index 0000000..43b3238 --- /dev/null +++ b/lib/open_pgp/literal_data_packet.ex @@ -0,0 +1,107 @@ +defmodule OpenPGP.LiteralDataPacket do + @moduledoc """ + Represents structured data for Literal Data Packet. + + ### Example: + + iex> alias OpenPGP.LiteralDataPacket + ...> data = <<0x62, 11, "example.txt", 1704328052::32, "Hello!">> + ...> LiteralDataPacket.decode(data) + { + %LiteralDataPacket{ + created_at: ~U[2024-01-04 00:27:32Z], + data: "Hello!", + file_name: "example.txt", + format: {<<0x62>>, :binary} + }, + <<>> + } + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 5.9. Literal Data Packet (Tag 11) + + A Literal Data packet contains the body of a message; data that is + not to be further interpreted. + + The body of this packet consists of: + + - A one-octet field that describes how the data is formatted. + + If it is a 'b' (0x62), then the Literal packet contains binary data. + If it is a 't' (0x74), then it contains text data, and thus may need + line ends converted to local form, or other text-mode changes. The + tag 'u' (0x75) means the same as 't', but also indicates that + implementation believes that the literal data contains UTF-8 text. + + Early versions of PGP also defined a value of 'l' as a 'local' mode + for machine-local conversions. RFC 1991 [RFC1991] incorrectly stated + this local mode flag as '1' (ASCII numeral one). Both of these local + modes are deprecated. + + - File name as a string (one-octet length, followed by a file + name). This may be a zero-length string. Commonly, if the + source of the encrypted data is a file, this will be the name of + the encrypted file. An implementation MAY consider the file name + in the Literal packet to be a more authoritative name than the + actual file name. + + If the special name "_CONSOLE" is used, the message is considered to + be "for your eyes only". This advises that the message data is + unusually sensitive, and the receiving program should process it more + carefully, perhaps avoiding storing the received data to disk, for + example. + + - A four-octet number that indicates a date associated with the + literal data. Commonly, the date might be the modification date + of a file, or the time the packet was created, or a zero that + indicates no specific time. + + - The remainder of the packet is literal data. + + Text data is stored with text endings (i.e., network- + normal line endings). These should be converted to native line + endings by the receiving software. + """ + + @behaviour OpenPGP.Packet.Behaviour + + defstruct [:format, :file_name, :created_at, :data] + + @type t :: %__MODULE__{ + created_at: DateTime.t(), + data: binary(), + file_name: binary(), + format: {<<_::8>>, :binary | :text | :text_utf8} + } + + @formats %{ + <<0x62::8>> => :binary, + <<0x74::8>> => :text, + <<0x75::8>> => :text_utf8 + } + @format_ids Map.keys(@formats) + + @doc """ + Decode Literal Data Packet given input binary. + Return structured packet and remaining binary (empty binary). + """ + @impl OpenPGP.Packet.Behaviour + @spec decode(binary()) :: {t(), <<>>} + def decode(<>) when format in @format_ids do + <> = next + + created_at = DateTime.from_unix!(timestamp) + + packet = %__MODULE__{ + format: {format, @formats[format]}, + file_name: fname, + created_at: created_at, + data: data + } + + {packet, ""} + end +end diff --git a/lib/open_pgp/packet.ex b/lib/open_pgp/packet.ex new file mode 100644 index 0000000..f8fbacd --- /dev/null +++ b/lib/open_pgp/packet.ex @@ -0,0 +1,83 @@ +defmodule OpenPGP.Packet do + @moduledoc """ + Packet struct represents a generic packet with a packet tag and a + body as a list of body chunks (see `OpenPGP.Packet.BodyChunk`). This + abstraction layer operates at Packet Tag and Packet Body level only. + To interpret the contents of a packet, the packet body should be + decoded at another abstraction layer with packet tag-specific + decoders, for exampe `OpenPGP.LiteralDataPacket` + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 4.1. Overview + + An OpenPGP message is constructed from a number of records that are + traditionally called packets. A packet is a chunk of data that has a + tag specifying its meaning. An OpenPGP message, keyring, + certificate, and so forth consists of a number of packets. Some of + those packets may contain other OpenPGP packets (for example, a + compressed data packet, when uncompressed, contains OpenPGP packets). + + Each packet consists of a packet header, followed by the packet body. + The packet header is of variable length. + """ + + @behaviour OpenPGP.Packet.Behaviour + + alias __MODULE__.BodyChunk + alias __MODULE__.PacketTag + + defstruct [:tag, :body] + + @type t :: %__MODULE__{ + tag: PacketTag.t(), + body: [BodyChunk.t()] + } + + @doc """ + Decode packet given input binary. + Return structured packet and remaining binary. + Expect input to start with the Packet Tag octet. + + ### Example: + + iex> alias OpenPGP.Packet + iex> alias OpenPGP.Packet.PacketTag + iex> alias OpenPGP.Packet.BodyChunk + iex> Packet.decode(<<1::1, 0::1, 2::4, 0::2, 7::8, "Hello, World!!!">>) + { + %Packet{ + tag: %PacketTag{format: :old, length_type: {0, "one-octet"}, tag: {2, "Signature Packet"}}, + body: [%BodyChunk{chunk_length: {:fixed, 7}, data: "Hello, ", header_length: 1}] + }, + "World!!!" + } + """ + @impl OpenPGP.Packet.Behaviour + @spec decode(binary()) :: {t(), binary()} + def decode("" <> _ = input) do + {ptag, next} = PacketTag.decode(input) + {chunks, rest} = collect_chunks(next, ptag, []) + + packet = %__MODULE__{tag: ptag, body: chunks} + + {packet, rest} + 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 + case BodyChunk.decode(input, ptag) do + {%BodyChunk{chunk_length: {:fixed, _}} = chunk, rest} -> + {Enum.reverse([chunk | acc]), rest} + + {%BodyChunk{chunk_length: {:indeterminate, _}} = chunk, rest} -> + {Enum.reverse([chunk | acc]), rest} + + {%BodyChunk{chunk_length: {:partial, _}} = chunk, rest} -> + collect_chunks(rest, ptag, [chunk | acc]) + end + end +end diff --git a/lib/open_pgp/packet/behaviour.ex b/lib/open_pgp/packet/behaviour.ex new file mode 100644 index 0000000..572eb1e --- /dev/null +++ b/lib/open_pgp/packet/behaviour.ex @@ -0,0 +1,17 @@ +defmodule OpenPGP.Packet.Behaviour do + @moduledoc """ + All packet specific decoders must implement + `OpenPGP.Packet.Behaviour`, which exposes `.decode/1` interface + (including genric `OpenPGP.Packet`). Additionaly some of the packet + specific decoders may provide interface for further packet processing, + such as `OpenPGP.SecretKeyPacket.decrypt/2`. + """ + + @doc """ + This callback is widely used to provide a clear interface to decoding + OpenPGP packets. All tag-specific packets implement this callback: + accepting a binary input and returning a two element tuple with a + decoded packet and a remainder of an input binary. + """ + @callback decode(binary()) :: {OpenPGP.any_packet(), binary()} +end diff --git a/lib/open_pgp/packet/body_chunk.ex b/lib/open_pgp/packet/body_chunk.ex new file mode 100644 index 0000000..3e46b92 --- /dev/null +++ b/lib/open_pgp/packet/body_chunk.ex @@ -0,0 +1,196 @@ +defmodule OpenPGP.Packet.BodyChunk do + @moduledoc """ + Packet data is represented as a list of body chunks. Each body + chunk includes chunk length header information and chunk data. + + Most of the packets will have only one body chunk. Only packets with + Partial Body Length expected to have more than one body chunk. + + The `OpenPGP.Util.concat_body/1` can be applied to a packet body or a + list of body chunks to concatenate chunks into a binary to be further + interpreted. + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + #### 4.2.1. Old Format Packet Lengths + + The meaning of the length-type in old format packets is: + + 0 - The packet has a one-octet length. The header is 2 octets long. + + 1 - The packet has a two-octet length. The header is 3 octets long. + + 2 - The packet has a four-octet length. The header is 5 octets long. + + 3 - The packet is of indeterminate length. The header is 1 octet + long, and the implementation must determine how long the packet + is. If the packet is in a file, this means that the packet + extends until the end of the file. In general, an implementation + SHOULD NOT use indeterminate-length packets except where the end + of the data will be clear from the context, and even then it is + better to use a definite length, or a new format header. The new + format headers described below have a mechanism for precisely + encoding data of indeterminate length. + + ### 4.2.2. New Format Packet Lengths + + New format packets have four possible ways of encoding length: + + 1. A one-octet Body Length header encodes packet lengths of up to 191 + octets. + + 2. A two-octet Body Length header encodes packet lengths of 192 to + 8383 octets. + + 3. A five-octet Body Length header encodes packet lengths of up to + 4,294,967,295 (0xFFFFFFFF) octets in length. (This actually + encodes a four-octet scalar number.) + + 4. When the length of the packet body is not known in advance by the + issuer, Partial Body Length headers encode a packet of + indeterminate length, effectively making it a stream. + + #### 4.2.2.1. One-Octet Lengths + + A one-octet Body Length header encodes a length of 0 to 191 octets. + This type of length header is recognized because the one octet value + is less than 192. The body length is equal to: + + bodyLen = 1st_octet; + + #### 4.2.2.2. Two-Octet Lengths + + A two-octet Body Length header encodes a length of 192 to 8383 + octets. It is recognized because its first octet is in the range 192 + to 223. The body length is equal to: + + bodyLen = ((1st_octet - 192) << 8) + (2nd_octet) + 192 + + #### 4.2.2.3. Five-Octet Lengths + + A five-octet Body Length header consists of a single octet holding + the value 255, followed by a four-octet scalar. The body length is + equal to: + + bodyLen = (2nd_octet << 24) | (3rd_octet << 16) | + (4th_octet << 8) | 5th_octet + + This basic set of one, two, and five-octet lengths is also used + internally to some packets. + + #### 4.2.2.4. Partial Body Lengths + + A Partial Body Length header is one octet long and encodes the length + of only part of the data packet. This length is a power of 2, from 1 + to 1,073,741,824 (2 to the 30th power). It is recognized by its one + octet value that is greater than or equal to 224, and less than 255. + The Partial Body Length is equal to: + + partialBodyLen = 1 << (1st_octet & 0x1F); + + Each Partial Body Length header is followed by a portion of the + packet body data. The Partial Body Length header specifies this + portion's length. Another length header (one octet, two-octet, + five-octet, or partial) follows that portion. The last length header + in the packet MUST NOT be a Partial Body Length header. Partial Body + Length headers may only be used for the non-final parts of the + packet. + + Note also that the last Body Length header can be a zero-length + header. + + An implementation MAY use Partial Body Lengths for data packets, be + they literal, compressed, or encrypted. The first partial length + MUST be at least 512 octets long. Partial Body Lengths MUST NOT be + used for any other packet types. + """ + + import Bitwise + alias OpenPGP.Packet.PacketTag, as: PTag + + defstruct [:data, :header_length, :chunk_length] + + @type t :: %__MODULE__{ + header_length: header_length(), + chunk_length: chunk_length(), + data: binary() + } + @typedoc "The packet has a N-octet length header." + @type header_length :: non_neg_integer() + + @typedoc "Chunk length in octets (bytes). Payload size." + @type chunk_length :: {:fixed | :partial | :indeterminate, non_neg_integer()} + + @doc """ + Decode body chunk given input binary and packet tag. + Return structured body chunk and remaining binary. + Expect input to start with Body Length header octets. + + ### Example: + + iex> alias OpenPGP.Packet.{BodyChunk, PacketTag} + iex> BodyChunk.decode(<<5, "Hello", " world!">>, %PacketTag{format: :old, length_type: {0, "one-octet"}}) + {%BodyChunk{data: "Hello", header_length: 1, chunk_length: {:fixed, 5}}, " world!"} + """ + @spec decode(input :: binary(), PTag.t()) :: {t(), rest :: binary()} + def decode("" <> _ = input, %PTag{} = ptag) do + {header_length, {_, blen} = chunk_length, next} = length_header(input, ptag) + + <> = next + chunk = %__MODULE__{data: data, header_length: header_length, chunk_length: chunk_length} + + {chunk, rest} + end + + @spec length_header(data :: binary(), PTag.t()) :: + {header_length(), chunk_length(), rest :: binary()} + defp length_header(<>, %PTag{format: :old, length_type: {0, _}}) do + {1, {:fixed, blength}, rest} + end + + defp length_header(<>, %PTag{format: :old, length_type: {1, _}}) do + {2, {:fixed, blength}, rest} + end + + defp length_header(<>, %PTag{format: :old, length_type: {2, _}}) do + {4, {:fixed, blength}, rest} + end + + defp length_header(<>, %PTag{format: :old, length_type: {3, _}}) do + {0, {:indeterminate, byte_size(rest)}, rest} + end + + defp length_header(<>, %PTag{format: :new}) when blength < 192 do + {1, {:fixed, blength}, rest} + end + + defp length_header(<>, %PTag{format: :new}) when b1 in 192..223 do + blength = ((b1 - 192) <<< 8) + b2 + 192 + {2, {:fixed, blength}, rest} + end + + defp length_header(<<255::8, blength::32, rest::binary>>, %PTag{format: :new}) do + {5, {:fixed, blength}, rest} + end + + # To understand this case, we need to look at 224-255 on a bit level: + + # 0x1F = 0b00011111 + # 224 = 0b11100000 + # 254 = 0b11111110 (255 is taken by five-octet length header) + + # The partial length header has all ones in the three most significant bits. + # Then, whatever number we have in the five least significant bits will be + # the power of two, according to the formula `1 << (1st_octet & 0x1F)`. + + # Example: + + # The encoded body length of 64 (2**6) in one octet partial length header on + # a bit level will be <<0b11100110::8>> (which is 230). + # To verify: `1 << (230 & 0x1F) = 64` + defp length_header(<>, %PTag{format: :new}) when b1 in 224..254 do + plength = 1 <<< (b1 &&& 0x1F) + {1, {:partial, plength}, rest} + end +end diff --git a/lib/open_pgp/packet/packet_tag.ex b/lib/open_pgp/packet/packet_tag.ex new file mode 100644 index 0000000..fd2c2fb --- /dev/null +++ b/lib/open_pgp/packet/packet_tag.ex @@ -0,0 +1,167 @@ +defmodule OpenPGP.Packet.PacketTag do + @moduledoc """ + PacketTag struct represents a packet tag as per RFC4880. + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 4.2. Packet Headers + + The first octet of the packet header is called the "Packet Tag". It + determines the format of the header and denotes the packet contents. + Note that the most significant bit is the leftmost bit, called bit 7. + A mask for this bit is 0x80 in hexadecimal. + + +---------------+ + PTag |7 6 5 4 3 2 1 0| + +---------------+ + Bit 7 -- Always one + Bit 6 -- New packet format if set + + Note that old format packets have four bits of + packet tags, and new format packets have six; some features cannot be + used and still be backward-compatible. + + Also note that packets with a tag greater than or equal to 16 MUST + use new format packets. The old format packets can only express tags + less than or equal to 15. + + Old format packets contain: + + Bits 5-2 -- packet tag + Bits 1-0 -- length-type + + New format packets contain: + + Bits 5-0 -- packet tag + + #### 4.2.1. Old Format Packet Lengths + + The meaning of the length-type in old format packets is: + + 0 - The packet has a one-octet length. The header is 2 octets long. + + 1 - The packet has a two-octet length. The header is 3 octets long. + + 2 - The packet has a four-octet length. The header is 5 octets long. + + 3 - The packet is of indeterminate length. The header is 1 octet + long, and the implementation must determine how long the packet + is. If the packet is in a file, this means that the packet + extends until the end of the file. In general, an implementation + SHOULD NOT use indeterminate-length packets except where the end + of the data will be clear from the context, and even then it is + better to use a definite length, or a new format header. The new + format headers described below have a mechanism for precisely + encoding data of indeterminate length. + + ### 4.3. Packet Tags + + The packet tag denotes what type of packet the body holds. Note that + old format headers can only have tags less than 16, whereas new + format headers can have tags as great as 63. The defined tags (in + decimal) are as follows: + + 0 -- Reserved - a packet tag MUST NOT have this value + 1 -- Public-Key Encrypted Session Key Packet + 2 -- Signature Packet + 3 -- Symmetric-Key Encrypted Session Key Packet + 4 -- One-Pass Signature Packet + 5 -- Secret-Key Packet + 6 -- Public-Key Packet + 7 -- Secret-Subkey Packet + 8 -- Compressed Data Packet + 9 -- Symmetrically Encrypted Data Packet + 10 -- Marker Packet + 11 -- Literal Data Packet + 12 -- Trust Packet + 13 -- User ID Packet + 14 -- Public-Subkey Packet + 17 -- User Attribute Packet + 18 -- Sym. Encrypted and Integrity Protected Data Packet + 19 -- Modification Detection Code Packet + 60 to 63 -- Private or Experimental Values + """ + + defstruct [:format, :tag, :length_type] + + @type t :: %__MODULE__{ + format: :old | :new, + tag: tag_tuple(), + length_type: length_tuple() | nil + } + @type tag_tuple :: {non_neg_integer(), desc :: binary()} + @type length_tuple :: {non_neg_integer(), desc :: binary()} + + @doc """ + Decode packet tag given input binary. + Return structured packet tag and remaining binary. + Expect input to start with the Packet Tag octet. + + ### Example: + + iex> alias OpenPGP.Packet.PacketTag + iex> PacketTag.decode(<<1::1, 0::1, 2::4, 0::2, "data">>) + {%PacketTag{format: :old, tag: {2, "Signature Packet"}, length_type: {0, "one-octet"}}, "data"} + """ + @spec decode(data :: binary()) :: {t(), rest :: binary()} + def decode(<<1::1, 0::1, tag::4, length_type::2, rest::binary>>) do + ptag = %__MODULE__{ + format: :old, + tag: tag_tuple(tag), + length_type: length_type_tuple(length_type) + } + + {ptag, rest} + end + + def decode(<<1::1, 1::1, tag::6, rest::binary>>) do + ptag = %__MODULE__{ + format: :new, + tag: tag_tuple(tag) + } + + {ptag, rest} + end + + @ptags %{ + 0 => "Reserved - a packet tag MUST NOT have this value", + 1 => "Public-Key Encrypted Session Key Packet", + 2 => "Signature Packet", + 3 => "Symmetric-Key Encrypted Session Key Packet", + 4 => "One-Pass Signature Packet", + 5 => "Secret-Key Packet", + 6 => "Public-Key Packet", + 7 => "Secret-Subkey Packet", + 8 => "Compressed Data Packet", + 9 => "Symmetrically Encrypted Data Packet", + 10 => "Marker Packet", + 11 => "Literal Data Packet", + 12 => "Trust Packet", + 13 => "User ID Packet", + 14 => "Public-Subkey Packet", + 17 => "User Attribute Packet", + 18 => "Sym. Encrypted and Integrity Protected Data Packet", + 19 => "Modification Detection Code Packet", + 60 => "Private or Experimental Values", + 61 => "Private or Experimental Values", + 62 => "Private or Experimental Values", + 63 => "Private or Experimental Values" + } + + @ptag_values Map.keys(@ptags) + @spec tag_tuple(non_neg_integer()) :: tag_tuple() + defp tag_tuple(tag) when tag in @ptag_values, do: {tag, @ptags[tag]} + + @plength %{ + 0 => "one-octet", + 1 => "two-octet", + 2 => "four-octet", + 3 => "indeterminate" + } + + @plength_values Map.keys(@plength) + @spec length_type_tuple(non_neg_integer()) :: length_tuple() + defp length_type_tuple(ltype) when ltype in @plength_values, do: {ltype, @plength[ltype]} +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 new file mode 100644 index 0000000..e901b32 --- /dev/null +++ b/lib/open_pgp/public_key_encrypted_session_key_packet.ex @@ -0,0 +1,159 @@ +defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacket do + @moduledoc """ + Represents structured data for Public-Key Encrypted SessionKey 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. + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 5.1. Public-Key Encrypted Session Key Packets (Tag 1) + + A Public-Key Encrypted Session Key packet holds the session key used + to encrypt a message. Zero or more Public-Key Encrypted Session Key + packets and/or Symmetric-Key Encrypted Session Key packets may + precede a Symmetrically Encrypted Data Packet, which holds an + encrypted message. The message is encrypted with the session key, + and the session key is itself encrypted and stored in the Encrypted + Session Key packet(s). The Symmetrically Encrypted Data Packet is + preceded by one Public-Key Encrypted Session Key packet for each + OpenPGP key to which the message is encrypted. The recipient of the + message finds a session key that is encrypted to their public key, + decrypts the session key, and then uses the session key to decrypt + the message. + + The body of this packet consists of: + + - A one-octet number giving the version number of the packet type. + The currently defined value for packet version is 3. + + - An eight-octet number that gives the Key ID of the public key to + which the session key is encrypted. If the session key is + encrypted to a subkey, then the Key ID of this subkey is used + here instead of the Key ID of the primary key. + + - A one-octet number giving the public-key algorithm used. + + - A string of octets that is the encrypted session key. This + string takes up the remainder of the packet, and its contents are + dependent on the public-key algorithm used. + + Algorithm Specific Fields for RSA encryption + + - multiprecision integer (MPI) of RSA encrypted value m**e mod n. + + Algorithm Specific Fields for Elgamal encryption: + + - MPI of Elgamal (Diffie-Hellman) value g**k mod p. + + - MPI of Elgamal (Diffie-Hellman) value m * y**k mod p. + + The value "m" in the above formulas is derived from the session key + as follows. First, the session key is prefixed with a one-octet + algorithm identifier that specifies the symmetric encryption + algorithm used to encrypt the following Symmetrically Encrypted Data + Packet. Then a two-octet checksum is appended, which is equal to the + sum of the preceding session key octets, not including the algorithm + identifier, modulo 65536. This value is then encoded as described in + PKCS#1 block encoding EME-PKCS1-v1_5 in Section 7.2.1 of [RFC3447] to + form the "m" value used in the formulas above. See Section 13.1 of + this document for notes on OpenPGP's use of PKCS#1. + + Note that when an implementation forms several PKESKs with one + session key, forming a message that can be decrypted by several keys, + the implementation MUST make a new PKCS#1 encoding for each key. + + An implementation MAY accept or use a Key ID of zero as a "wild card" + or "speculative" Key ID. In this case, the receiving implementation + would try all available private keys, checking for a valid decrypted + session key. This format helps reduce traffic analysis of messages. + """ + + @behaviour OpenPGP.Packet.Behaviour + + alias OpenPGP.PublicKeyPacket, as: PKPacket + alias OpenPGP.SecretKeyPacket, as: SKPacket + alias OpenPGP.Util + + defstruct [ + :version, + :public_key_id, + :public_key_algo, + :ciphertext, + :session_key_algo, + :session_key_material + ] + + @type t :: %__MODULE__{ + version: byte(), + public_key_id: binary(), + public_key_algo: Util.public_key_algo_tuple(), + ciphertext: binary(), + session_key_algo: Util.sym_algo_tuple() | nil, + session_key_material: tuple() | nil + } + + @doc """ + Decode Public-Key Encrypted SessionKey Packet given input binary. + Return structured packet and remaining binary. + """ + @impl OpenPGP.Packet.Behaviour + @spec decode(binary()) :: {t(), <<>>} + def decode("" <> _ = input) do + <> = input + + packet = %__MODULE__{ + version: version, + public_key_id: pub_key_id, + public_key_algo: Util.public_key_algo_tuple(pub_key_algo), + ciphertext: ciphertext + } + + {packet, ""} + end + + @doc """ + Decrypt Public-Key Encrypted Session Key Packet given decoded + Public-Key Encrypted Session Key Packet and decoded and decrypted + Secret-Key Packet. + Return Public-Key Encrypted Session Key Packet with + `:session_key_algo` and `:session_key_material` attrs assigned. + Raises an error if checksum does not match. + """ + @spec decrypt(t(), SKPacket.t()) :: t() + def decrypt(%__MODULE__{} = packet, %SKPacket{} = sk_packet) do + %SKPacket{ + public_key: %PKPacket{ + algo: {1, _}, + material: {mod_n, exp_e} + }, + secret_key_material: {exp_d, _, _, _} + } = sk_packet + + {encrypted_session_key, ""} = Util.decode_mpi(packet.ciphertext) + + priv_key = [exp_e, mod_n, exp_d] + payload = :crypto.private_decrypt(:rsa, encrypted_session_key, priv_key, []) + + bsize = byte_size(payload) - 2 - 1 + <> = payload + octets = for <>, do: b + actual_checksum = octets |> Enum.sum() |> rem(65536) + + if expected_checksum == actual_checksum do + %{ + packet + | session_key_algo: Util.sym_algo_tuple(sym_key_algo), + session_key_material: {session_key} + } + else + msg = "Expected PublicKeyEncryptedSessionKeyPacket checksum to be #{expected_checksum}, got #{actual_checksum}." + + raise(msg) + end + end +end diff --git a/lib/open_pgp/public_key_packet.ex b/lib/open_pgp/public_key_packet.ex new file mode 100644 index 0000000..f6df455 --- /dev/null +++ b/lib/open_pgp/public_key_packet.ex @@ -0,0 +1,157 @@ +defmodule OpenPGP.PublicKeyPacket do + @moduledoc """ + Represents structured data for Public-Key Packet. + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 5.5.2. Public-Key Packet Formats + + There are two versions of key-material packets. Version 3 packets + were first generated by PGP 2.6. Version 4 keys first appeared in + PGP 5.0 and are the preferred key version for OpenPGP. + + OpenPGP implementations MUST create keys with version 4 format. V3 + keys are deprecated; an implementation MUST NOT generate a V3 key, + but MAY accept it. + + A version 3 public key or public-subkey packet contains: + + - A one-octet version number (3). + + - A four-octet number denoting the time that the key was created. + + - A two-octet number denoting the time in days that this key is + valid. If this number is zero, then it does not expire. + + - A one-octet number denoting the public-key algorithm of this key. + + - A series of multiprecision integers comprising the key material: + + - a multiprecision integer (MPI) of RSA public modulus n; + + - an MPI of RSA public encryption exponent e. + + V3 keys are deprecated. They contain three weaknesses. First, it is + relatively easy to construct a V3 key that has the same Key ID as any + other key because the Key ID is simply the low 64 bits of the public + modulus. Secondly, because the fingerprint of a V3 key hashes the + key material, but not its length, there is an increased opportunity + for fingerprint collisions. Third, there are weaknesses in the MD5 + hash algorithm that make developers prefer other algorithms. See + below for a fuller discussion of Key IDs and fingerprints. + + V2 keys are identical to the deprecated V3 keys except for the + version number. An implementation MUST NOT generate them and MAY + accept or reject them as it sees fit. + + The version 4 format is similar to the version 3 format except for + the absence of a validity period. This has been moved to the + Signature packet. In addition, fingerprints of version 4 keys are + calculated differently from version 3 keys, as described in the + section "Enhanced Key Formats". + + A version 4 packet contains: + + - A one-octet version number (4). + + - A four-octet number denoting the time that the key was created. + + - A one-octet number denoting the public-key algorithm of this key. + + - A series of multiprecision integers comprising the key material. + This algorithm-specific portion is: + + Algorithm-Specific Fields for RSA public keys: + + - multiprecision integer (MPI) of RSA public modulus n; + + - MPI of RSA public encryption exponent e. + + Algorithm-Specific Fields for DSA public keys: + + - MPI of DSA prime p; + + - MPI of DSA group order q (q is a prime divisor of p-1); + + - MPI of DSA group generator g; + + - MPI of DSA public-key value y (= g**x mod p where x + is secret). + + Algorithm-Specific Fields for Elgamal public keys: + + - MPI of Elgamal prime p; + + - MPI of Elgamal group generator g; + - MPI of Elgamal public key value y (= g**x mod p where x + is secret). + """ + + @behaviour OpenPGP.Packet.Behaviour + + alias OpenPGP.Util + + defstruct [:id, :fingerprint, :version, :created_at, :expires, :algo, :material] + + @type t :: %__MODULE__{ + id: binary(), + fingerprint: binary(), + version: 2 | 3 | 4, + created_at: DateTime.t(), + expires: nil | non_neg_integer(), + algo: OpenPGP.Util.public_key_algo_tuple(), + material: tuple() + } + + @doc """ + Decode Public Key Packet given input binary. + Return structured packet and remaining binary. + """ + @impl OpenPGP.Packet.Behaviour + @spec decode(binary()) :: {t(), binary()} + def decode("" <> _ = input) do + {version, timestamp, expire, algo, next} = + case input do + <<2::8, ts::32, exp::16, algo::8, next::binary>> -> {2, ts, exp, algo, next} + <<3::8, ts::32, exp::16, algo::8, next::binary>> -> {3, ts, exp, algo, next} + <<4::8, ts::32, algo::8, next::binary>> -> {4, ts, nil, algo, next} + end + + {material, next} = decode_material(algo, next) + {key_id, fingerprint} = build_key_id(input, next) + + created_at = DateTime.from_unix!(timestamp) + + packet = %__MODULE__{ + id: key_id, + fingerprint: fingerprint, + version: version, + created_at: created_at, + expires: expire, + algo: Util.public_key_algo_tuple(algo), + material: material + } + + {packet, next} + end + + # Support only RSA as of version 0.5.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) + + {{mod_n, exp_e}, rest} + end + + defp build_key_id("" <> _ = input, "" <> _ = next) do + payload_length = byte_size(input) - byte_size(next) + <> = input + hash_material = <<0x99::8, payload_length::2*8, payload::binary>> + fingerprint = :crypto.hash(:sha, hash_material) + <<_::96, id::binary-size(8)>> = fingerprint + + {id, fingerprint} + end +end diff --git a/lib/open_pgp/radix64.ex b/lib/open_pgp/radix64.ex new file mode 100644 index 0000000..f07d7d7 --- /dev/null +++ b/lib/open_pgp/radix64.ex @@ -0,0 +1,69 @@ +defmodule OpenPGP.Radix64 do + @moduledoc """ + Radix64 decoder, as per [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + """ + + alias OpenPGP.Radix64.CRC24 + alias OpenPGP.Radix64.Entry + + @spec decode(binary()) :: [Entry.t()] + def decode("" <> _ = input) do + input + |> String.split("\n") + |> Stream.chunk_while(%Entry{}, &chunk_fun/2, &after_fun/1) + |> Enum.to_list() + end + + @spec chunk_fun(binary(), Entry.t()) :: {:cont, Entry.t()} | {:cont, Entry.t(), Entry.t()} + @header_lines [ + "PGP MESSAGE", + "PGP PUBLIC KEY BLOCK", + "PGP PRIVATE KEY BLOCK", + "PGP SIGNATURE" + # "PGP MESSAGE, PART X/Y" + # "PGP MESSAGE, PART X" + ] + for hline <- @header_lines do + defp chunk_fun("-----BEGIN #{unquote(hline)}-----" <> _, %Entry{}) do + {:cont, %Entry{name: unquote(hline), data: <<>>}} + end + + defp chunk_fun("-----END #{unquote(hline)}-----" <> _, %Entry{name: unquote(hline)} = entry) do + {:cont, validate_checksum!(%{entry | data: Base.decode64!(entry.data)}), %Entry{}} + end + end + + @meta_keys ~w[Version Comment MessageID Hash Charset] + for key <- @meta_keys do + defp chunk_fun(unquote(key) <> ": " <> meta_value, %Entry{} = entry) do + {:cont, %{entry | meta: [{unquote(key), String.trim(meta_value)} | entry.meta]}} + end + end + + defp chunk_fun("=" <> crc, %Entry{} = entry) do + {:cont, %{entry | crc: Base.decode64!(String.trim(crc))}} + end + + defp chunk_fun("" <> _ = line, %Entry{} = entry) do + {:cont, %{entry | data: entry.data <> String.trim(line)}} + end + + @spec after_fun(Entry.t()) :: {:cont, Entry.t()} + defp after_fun(%Entry{name: nil, meta: [], data: <<>>} = entry), do: {:cont, entry} + defp after_fun(%Entry{} = entry), do: raise("Nonempty state: #{inspect(entry)}") + + @spec validate_checksum!(Entry.t()) :: Entry.t() + defp validate_checksum!(%Entry{} = entry) do + actual_crc = CRC24.calc(entry.data) + + if actual_crc != entry.crc do + msg = + "CRC24 checksum for entry #{inspect(entry.name)} does not match. " <> + "Expected #{inspect(entry.crc)}, got #{inspect(actual_crc)}." + + raise(msg) + else + entry + end + end +end diff --git a/lib/open_pgp/radix64/crc24.ex b/lib/open_pgp/radix64/crc24.ex new file mode 100644 index 0000000..4ad9c2b --- /dev/null +++ b/lib/open_pgp/radix64/crc24.ex @@ -0,0 +1,73 @@ +defmodule OpenPGP.Radix64.CRC24 do + @moduledoc """ + CRC-24 implementation for Radix-64 checksum validation. + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 6. Radix-64 Conversions + + The checksum is a 24-bit Cyclic Redundancy Check (CRC) converted to + four characters of radix-64 encoding by the same MIME base64 + transformation, preceded by an equal sign (=). The CRC is computed + by using the generator 0x864CFB and an initialization of 0xB704CE. + The accumulation is done on the data before it is converted to + radix-64, rather than on the converted data. + + + ### 6.1. An Implementation of the CRC-24 in "C" + + ``` + #define CRC24_INIT 0xB704CEL + #define CRC24_POLY 0x1864CFBL + + typedef long crc24; + crc24 crc_octets(unsigned char *octets, size_t len) + { + crc24 crc = CRC24_INIT; + int i; + while (len--) { + crc ^= (*octets++) << 16; + for (i = 0; i < 8; i++) { + crc <<= 1; + if (crc & 0x1000000) + crc ^= CRC24_POLY; + } + } + return crc & 0xFFFFFFL; + } + ``` + """ + import Bitwise + + @crc24_init 0xB704CE + @crc24_poly 0x1864CFB + + @doc """ + Calculate CRC-24 of a given binary. + + ### Example: + + iex> OpenPGP.Radix64.CRC24.calc("Hello, world!!!") + <<190, 125, 81>> + """ + @spec calc(binary()) :: <<_::24>> + def calc("" <> _ = input) do + crc_sum = + for <>, reduce: @crc24_init do + acc -> running_sum(octet, acc) + end + + <> + end + + defp running_sum(octet, prev) do + crc = bxor(prev, octet <<< 16) + + Enum.reduce(0..7, crc, fn _, acc -> + acc = acc <<< 1 + if band(acc, 0x1000000) != 0, do: bxor(acc, @crc24_poly), else: acc + end) + end +end diff --git a/lib/open_pgp/radix64/entry.ex b/lib/open_pgp/radix64/entry.ex new file mode 100644 index 0000000..4b47729 --- /dev/null +++ b/lib/open_pgp/radix64/entry.ex @@ -0,0 +1,67 @@ +defmodule OpenPGP.Radix64.Entry do + @moduledoc """ + Represents a block/entry in the PGP armored message. + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + 6.2. Forming ASCII Armor + + When OpenPGP encodes data into ASCII Armor, it puts specific headers + around the Radix-64 encoded data, so OpenPGP can reconstruct the data + later. An OpenPGP implementation MAY use ASCII armor to protect raw + binary data. OpenPGP informs the user what kind of data is encoded + in the ASCII armor through the use of the headers. + + Concatenating the following data creates ASCII Armor: + + - An Armor Header Line, appropriate for the type of data + + - Armor Headers + + - A blank (zero-length, or containing only whitespace) line + + - The ASCII-Armored data + + - An Armor Checksum + + - The Armor Tail, which depends on the Armor Header Line + + An Armor Header Line consists of the appropriate header line text + surrounded by five (5) dashes ('-', 0x2D) on either side of the + header line text. The header line text is chosen based upon the type + of data that is being encoded in Armor, and how it is being encoded. + Header line texts include the following strings: + + BEGIN PGP MESSAGE + Used for signed, encrypted, or compressed files. + + BEGIN PGP PUBLIC KEY BLOCK + Used for armoring public keys. + + BEGIN PGP PRIVATE KEY BLOCK + Used for armoring private keys. + + BEGIN PGP MESSAGE, PART X/Y + Used for multi-part messages, where the armor is split amongst Y + parts, and this is the Xth part out of Y. + + BEGIN PGP MESSAGE, PART X + Used for multi-part messages, where this is the Xth part of an + unspecified number of parts. Requires the MESSAGE-ID Armor + Header to be used. + + BEGIN PGP SIGNATURE + Used for detached signatures, OpenPGP/MIME signatures, and + cleartext signatures. Note that PGP 2.x uses BEGIN PGP MESSAGE + for detached signatures. + """ + + defstruct name: nil, meta: [], data: <<>>, crc: nil + + @type t :: %__MODULE__{ + name: binary() | nil, + meta: [{key :: binary(), value :: binary()}], + data: binary(), + crc: binary() | nil + } +end diff --git a/lib/open_pgp/s2k_specifier.ex b/lib/open_pgp/s2k_specifier.ex new file mode 100644 index 0000000..765d9d9 --- /dev/null +++ b/lib/open_pgp/s2k_specifier.ex @@ -0,0 +1,335 @@ +# credo:disable-for-next-line CredoNaming.Check.Consistency.ModuleFilename +defmodule OpenPGP.S2KSpecifier do + @moduledoc """ + Represents structured data for String-to-Key specifier. + + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ## 3.7. String-to-Key (S2K) Specifiers + + String-to-key (S2K) specifiers are used to convert passphrase strings + into symmetric-key encryption/decryption keys. They are used in two + places, currently: to encrypt the secret part of private keys in the + private keyring, and to convert passphrases to encryption keys for + symmetrically encrypted messages. + + ### 3.7.1. String-to-Key (S2K) Specifier Types + + There are three types of S2K specifiers currently supported, and + some reserved values: + + ID S2K Type + -- -------- + 0 Simple S2K + 1 Salted S2K + 2 Reserved value + 3 Iterated and Salted S2K + 100 to 110 Private/Experimental S2K + + These are described in Sections 3.7.1.1 - 3.7.1.3. + + #### 3.7.1.1. Simple S2K + + This directly hashes the string to produce the key data. See below + for how this hashing is done. + + Octet 0: 0x00 + Octet 1: hash algorithm + + Simple S2K hashes the passphrase to produce the session key. The + manner in which this is done depends on the size of the session key + (which will depend on the cipher used) and the size of the hash + algorithm's output. If the hash size is greater than the session key + size, the high-order (leftmost) octets of the hash are used as the + key. + + If the hash size is less than the key size, multiple instances of the + hash context are created -- enough to produce the required key data. + These instances are preloaded with 0, 1, 2, ... octets of zeros (that + is to say, the first instance has no preloading, the second gets + preloaded with 1 octet of zero, the third is preloaded with two + octets of zeros, and so forth). + + As the data is hashed, it is given independently to each hash + context. Since the contexts have been initialized differently, they + will each produce different hash output. Once the passphrase is + hashed, the output data from the multiple hashes is concatenated, + first hash leftmost, to produce the key data, with any excess octets + on the right discarded. + + #### 3.7.1.2. Salted S2K + + This includes a "salt" value in the S2K specifier -- some arbitrary + data -- that gets hashed along with the passphrase string, to help + prevent dictionary attacks. + + Octet 0: 0x01 + Octet 1: hash algorithm + Octets 2-9: 8-octet salt value + + Salted S2K is exactly like Simple S2K, except that the input to the + hash function(s) consists of the 8 octets of salt from the S2K + specifier, followed by the passphrase. + + #### 3.7.1.3. Iterated and Salted S2K + + This includes both a salt and an octet count. The salt is combined + with the passphrase and the resulting value is hashed repeatedly. + This further increases the amount of work an attacker must do to try + dictionary attacks. + + Octet 0: 0x03 + Octet 1: hash algorithm + Octets 2-9: 8-octet salt value + Octet 10: count, a one-octet, coded value + The count is coded into a one-octet number using the following + formula: + + #define EXPBIAS 6 + count = ((Int32)16 + (c & 15)) << ((c >> 4) + EXPBIAS); + + The above formula is in C, where "Int32" is a type for a 32-bit + integer, and the variable "c" is the coded count, Octet 10. + + Iterated-Salted S2K hashes the passphrase and salt data multiple + times. The total number of octets to be hashed is specified in the + encoded count in the S2K specifier. Note that the resulting count + value is an octet count of how many octets will be hashed, not an + iteration count. + + Initially, one or more hash contexts are set up as with the other S2K + algorithms, depending on how many octets of key data are needed. + Then the salt, followed by the passphrase data, is repeatedly hashed + until the number of octets specified by the octet count has been + hashed. The one exception is that if the octet count is less than + the size of the salt plus passphrase, the full salt plus passphrase + will be hashed even though that is greater than the octet count. + After the hashing is done, the data is unloaded from the hash + context(s) as with the other S2K algorithms. + """ + import Bitwise + + @enforce_keys [:id] + defstruct [:id, :algo, :salt, :protect_count] + + @type t :: %__MODULE__{ + id: {byte(), any()}, + algo: nil | {0 | 1 | 2 | 3 | 100..110, binary()}, + protect_count: nil | {byte(), pos_integer()}, + salt: nil | binary() + } + + @doc """ + Decode String-to-Key specifier given input binary. + Return structured specifier and remaining binary. + """ + @spec decode(binary()) :: {t(), binary()} + def decode(<<0::8, algo::8, rest::binary>>) do + specifier = %__MODULE__{ + id: s2k_type_tuple(0), + algo: hash_algo_tuple(algo) + } + + {specifier, rest} + end + + def decode(<<1::8, algo::8, salt::binary-size(8), rest::binary>>) do + specifier = %__MODULE__{ + id: s2k_type_tuple(1), + algo: hash_algo_tuple(algo), + salt: salt + } + + {specifier, rest} + end + + def decode(<<3::8, algo::8, salt::binary-size(8), protect_count::8, rest::binary>>) do + specifier = %__MODULE__{ + id: s2k_type_tuple(3), + algo: hash_algo_tuple(algo), + salt: salt, + protect_count: {protect_count, decode_protect_count(protect_count)} + } + + {specifier, rest} + end + + def decode(<>) when id == 2 or id in 100..110 do + specifier = %__MODULE__{id: s2k_type_tuple(id)} + {specifier, rest} + end + + @spec build_session_key(t(), key_size :: pos_integer(), passphrase :: binary()) :: binary() + def build_session_key(%__MODULE__{} = specifier, key_size, "" <> _ = passphrase) + when is_integer(key_size) and key_size > 0 do + %__MODULE__{ + id: {3, _}, + salt: salt, + protect_count: {_, protect_count} + } = specifier + + build_session_key(key_size, passphrase, salt, protect_count) + end + + # [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + # + # --- + # + # ### 3.7.1.1. Simple S2K + # If the hash size is less than the key size, multiple instances of the + # hash context are created -- enough to produce the required key data. + # These instances are preloaded with 0, 1, 2, ... octets of zeros (that + # is to say, the first instance has no preloading, the second gets + # preloaded with 1 octet of zero, the third is preloaded with two + # octets of zeros, and so forth). + + # As the data is hashed, it is given independently to each hash + # context. Since the contexts have been initialized differently, they + # will each produce different hash output. Once the passphrase is + # hashed, the output data from the multiple hashes is concatenated, + # first hash leftmost, to produce the key data, with any excess octets + # on the right discarded. + # + # ... + # + # ### 3.7.1.3. Iterated and Salted S2K + # + # ... + # + # Iterated-Salted S2K hashes the passphrase and salt data multiple + # times. The total number of octets to be hashed is specified in the + # encoded count in the S2K specifier. Note that the resulting count + # value is an octet count of how many octets will be hashed, not an + # iteration count. + # Initially, one or more hash contexts are set up as with the other S2K + # algorithms, depending on how many octets of key data are needed. + # Then the salt, followed by the passphrase data, is repeatedly hashed + # until the number of octets specified by the octet count has been + # hashed. The one exception is that if the octet count is less than + # the size of the salt plus passphrase, the full salt plus passphrase + # will be hashed even though that is greater than the octet count. + # After the hashing is done, the data is unloaded from the hash + # context(s) as with the other S2K algorithms. + @max_hash_contexts 100 + @zero_octet <<0::8>> + @spec build_session_key( + key_bit_size :: non_neg_integer(), + passphrase :: binary(), + salt :: binary(), + protect_count :: non_neg_integer() + ) :: session_key :: binary() + defp build_session_key(key_bit_size, "" <> _ = passphrase, "" <> _ = salt, protect_count) do + salted_passphrase = salt <> passphrase + iter_count = ceil(protect_count / byte_size(salted_passphrase)) + + <> = + Enum.reduce(1..iter_count, "", fn _, acc -> acc <> salted_passphrase end) + + iterated_s2k_hash = + Enum.reduce_while(1..@max_hash_contexts, "", fn context_num, acc -> + if bit_size(acc) < key_bit_size do + prefix = String.pad_trailing("", context_num - 1, @zero_octet) + {:cont, acc <> :crypto.hash(:sha, prefix <> hash_input)} + else + {:halt, acc} + end + end) + + <> = iterated_s2k_hash + <> + end + + # 3.7.1. String-to-Key (S2K) Specifier Types + + # There are three types of S2K specifiers currently supported, and + # some reserved values: + + # ID S2K Type + # -- -------- + # 0 Simple S2K + # 1 Salted S2K + # 2 Reserved value + # 3 Iterated and Salted S2K + # 100 to 110 Private/Experimental S2K + + @s2k_types %{ + 0 => "Simple S2K", + 1 => "Salted S2K", + 2 => "Reserved value", + 3 => "Iterated and Salted S2K", + 100 => "Private/Experimental S2K", + 101 => "Private/Experimental S2K", + 102 => "Private/Experimental S2K", + 103 => "Private/Experimental S2K", + 104 => "Private/Experimental S2K", + 105 => "Private/Experimental S2K", + 106 => "Private/Experimental S2K", + 107 => "Private/Experimental S2K", + 108 => "Private/Experimental S2K", + 109 => "Private/Experimental S2K", + 110 => "Private/Experimental S2K" + } + + @s2k_types_values Map.keys(@s2k_types) + defp s2k_type_tuple(id) when id in @s2k_types_values, do: {id, @s2k_types[id]} + + # 9.4. Hash Algorithms + + # ID Algorithm Text Name + # -- --------- --------- + # 1 - MD5 [HAC] "MD5" + # 2 - SHA-1 [FIPS180] "SHA1" + # 3 - RIPE-MD/160 [HAC] "RIPEMD160" + # 4 - Reserved + # 5 - Reserved + # 6 - Reserved + # 7 - Reserved + # 8 - SHA256 [FIPS180] "SHA256" + # 9 - SHA384 [FIPS180] "SHA384" + # 10 - SHA512 [FIPS180] "SHA512" + # 11 - SHA224 [FIPS180] "SHA224" + # 100 to 110 - Private/Experimental algorithm + + # Implementations MUST implement SHA-1. Implementations MAY implement + # other algorithms. MD5 is deprecated. + + @hash_algos %{ + 1 => "MD5 [HAC]", + 2 => "SHA-1 [FIPS180]", + 3 => "RIPE-MD/160 [HAC]", + 4 => "Reserved", + 5 => "Reserved", + 6 => "Reserved", + 7 => "Reserved", + 8 => "SHA256 [FIPS180]", + 9 => "SHA384 [FIPS180]", + 10 => "SHA512 [FIPS180]", + 11 => "SHA224 [FIPS180]", + 100 => "Private/Experimental algorithm", + 101 => "Private/Experimental algorithm", + 102 => "Private/Experimental algorithm", + 103 => "Private/Experimental algorithm", + 104 => "Private/Experimental algorithm", + 105 => "Private/Experimental algorithm", + 106 => "Private/Experimental algorithm", + 107 => "Private/Experimental algorithm", + 108 => "Private/Experimental algorithm", + 109 => "Private/Experimental algorithm", + 110 => "Private/Experimental algorithm" + } + @hash_algo_values Map.keys(@hash_algos) + defp hash_algo_tuple(id) when id in @hash_algo_values, do: {id, @hash_algos[id]} + + # The count is coded into a one-octet number using the following + # formula: + + # #define EXPBIAS 6 + # count = ((Int32)16 + (c & 15)) << ((c >> 4) + EXPBIAS); + + # The above formula is in C, where "Int32" is a type for a 32-bit + # integer, and the variable "c" is the coded count, Octet 10. + @expbias 6 + defp decode_protect_count(c), do: (16 + (c &&& 15)) <<< ((c >>> 4) + @expbias) +end diff --git a/lib/open_pgp/secret_key_packet.ex b/lib/open_pgp/secret_key_packet.ex new file mode 100644 index 0000000..efc13c3 --- /dev/null +++ b/lib/open_pgp/secret_key_packet.ex @@ -0,0 +1,227 @@ +defmodule OpenPGP.SecretKeyPacket do + @v05x_note """ + As of 0.5.x Secret-Key Packet supports only: + + 1. V4 packets + 1. Iterated and Salted String-to-Key (S2K) specifier (ID: 3) + 1. S2K usage convention octet of 254 only + 1. S2K hashing algo SHA1 + 1. AES128 symmetric encryption of secret key material + """ + @moduledoc """ + Represents structured data for Secret-Key Packet. + + > NOTE: #{@v05x_note} + --- + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + 5.5.3. Secret-Key Packet Formats + + The Secret-Key and Secret-Subkey packets contain all the data of the + Public-Key and Public-Subkey packets, with additional algorithm- + specific secret-key data appended, usually in encrypted form. + + The packet contains: + + - A Public-Key or Public-Subkey packet, as described above. + + - One octet indicating string-to-key usage conventions. Zero + indicates that the secret-key data is not encrypted. 255 or 254 + indicates that a string-to-key specifier is being given. Any + other value is a symmetric-key encryption algorithm identifier. + + - [Optional] If string-to-key usage octet was 255 or 254, a one- + octet symmetric encryption algorithm. + + - [Optional] If string-to-key usage octet was 255 or 254, a + string-to-key specifier. The length of the string-to-key + specifier is implied by its type, as described above. + + - [Optional] If secret data is encrypted (string-to-key usage octet + not zero), an Initial Vector (IV) of the same length as the + cipher's block size. + + - Plain or encrypted multiprecision integers comprising the secret + key data. These algorithm-specific fields are as described + below. + + - If the string-to-key usage octet is zero or 255, then a two-octet + checksum of the plaintext of the algorithm-specific portion (sum + of all octets, mod 65536). If the string-to-key usage octet was + 254, then a 20-octet SHA-1 hash of the plaintext of the + algorithm-specific portion. This checksum or hash is encrypted + together with the algorithm-specific fields (if string-to-key + usage octet is not zero). Note that for all other values, a + two-octet checksum is required. + + Algorithm-Specific Fields for RSA secret keys: + + - multiprecision integer (MPI) of RSA secret exponent d. + + - MPI of RSA secret prime value p. + + - MPI of RSA secret prime value q (p < q). + + - MPI of u, the multiplicative inverse of p, mod q. + + Algorithm-Specific Fields for DSA secret keys: + + - MPI of DSA secret exponent x. + + Algorithm-Specific Fields for Elgamal secret keys: + + - MPI of Elgamal secret exponent x. + + Secret MPI values can be encrypted using a passphrase. If a string- + to-key specifier is given, that describes the algorithm for + converting the passphrase to a key, else a simple MD5 hash of the + passphrase is used. Implementations MUST use a string-to-key + specifier; the simple hash is for backward compatibility and is + deprecated, though implementations MAY continue to use existing + private keys in the old format. The cipher for encrypting the MPIs + is specified in the Secret-Key packet. + + Encryption/decryption of the secret data is done in CFB mode using + the key created from the passphrase and the Initial Vector from the + packet. A different mode is used with V3 keys (which are only RSA) + than with other key formats. With V3 keys, the MPI bit count prefix + (i.e., the first two octets) is not encrypted. Only the MPI non- + prefix data is encrypted. Furthermore, the CFB state is + resynchronized at the beginning of each new MPI value, so that the + CFB block boundary is aligned with the start of the MPI data. + + With V4 keys, a simpler method is used. All secret MPI values are + encrypted in CFB mode, including the MPI bitcount prefix. + + The two-octet checksum that follows the algorithm-specific portion is + the algebraic sum, mod 65536, of the plaintext of all the algorithm- + specific octets (including MPI prefix and data). With V3 keys, the + checksum is stored in the clear. With V4 keys, the checksum is + encrypted like the algorithm-specific data. This value is used to + check that the passphrase was correct. However, this checksum is + deprecated; an implementation SHOULD NOT use it, but should rather + use the SHA-1 hash denoted with a usage octet of 254. The reason for + this is that there are some attacks that involve undetectably + modifying the secret key. + """ + + @behaviour OpenPGP.Packet.Behaviour + + alias OpenPGP.Util + + defstruct [ + :public_key, + :s2k_usage, + :s2k_specifier, + :sym_key_algo, + :sym_key_initial_vector, + :sym_key_size, + :ciphertext, + :secret_key_material + ] + + alias OpenPGP.PublicKeyPacket + alias OpenPGP.S2KSpecifier + alias OpenPGP.Util + + @type t :: %__MODULE__{ + public_key: PublicKeyPacket.t(), + s2k_usage: {0..255, binary()}, + s2k_specifier: S2KSpecifier.t(), + sym_key_algo: OpenPGP.Util.sym_algo_tuple(), + sym_key_initial_vector: binary(), + sym_key_size: non_neg_integer(), + secret_key_material: tuple() | nil, + ciphertext: binary() + } + + @doc """ + Decode Secret Key Packet given input binary. + Return structured packet and remaining binary (empty binary). + """ + @impl OpenPGP.Packet.Behaviour + @spec decode(binary()) :: {t(), <<>>} + def decode("" <> _ = input) do + {public_key, next} = PublicKeyPacket.decode(input) + + case next do + <> when s2k_usage == 254 -> + {s2k_specifier, next} = S2KSpecifier.decode(next) + iv_size = Util.sym_algo_cipher_block_size(sym_algo) + <> = next + + packet = %__MODULE__{ + public_key: public_key, + s2k_specifier: s2k_specifier, + s2k_usage: s2k_usage_tuple(s2k_usage), + sym_key_algo: Util.sym_algo_tuple(sym_algo), + sym_key_initial_vector: iv, + sym_key_size: iv_size, + ciphertext: ciphertext + } + + {packet, ""} + end + end + + @doc """ + Decrypt Secret-Key Packet given decoded Secret-Key Packet and a + passphrase. + Return Secret-Key Packet with `:secret_key_material` attr assigned. + Raises an error if checksum does not match. + """ + @spec decrypt(t(), passphrase :: binary()) :: t() + def decrypt(%__MODULE__{} = packet, "" <> _ = passphrase) do + case packet do + %__MODULE__{public_key: %{version: 4}, s2k_usage: {254, _}, sym_key_algo: {7, _}} -> :ok + %__MODULE__{} -> raise(@v05x_note <> "\n Got: #{inspect(packet)}") + end + + %__MODULE__{ + sym_key_size: session_key_size, + sym_key_initial_vector: iv, + ciphertext: ciphertext, + s2k_specifier: s2k_specifier + } = packet + + session_key = S2KSpecifier.build_session_key(s2k_specifier, session_key_size, passphrase) + + plaintext = :crypto.crypto_one_time(:aes_128_cfb128, session_key, iv, ciphertext, false) + {data, _checksum} = validate_checksum!(plaintext) + + {secret_exp_d, next} = Util.decode_mpi(data) + {prime_val_p, next} = Util.decode_mpi(next) + {prime_val_q, next} = Util.decode_mpi(next) + {secret_u, ""} = Util.decode_mpi(next) + + material = {secret_exp_d, prime_val_p, prime_val_q, secret_u} + + %{packet | secret_key_material: material} + end + + @checksum_byte_size 20 + @spec validate_checksum!(binary()) :: {data :: binary(), checksum :: binary()} + defp validate_checksum!("" <> _ = plaintext) do + plaintext_byte_size = byte_size(plaintext) - @checksum_byte_size + + <> = plaintext + + actual_checksum = :crypto.hash(:sha, data) + + if actual_checksum == expected_checksum do + {data, actual_checksum} + else + expected_hex = expected_checksum |> Base.encode16() |> inspect() + actual_hex = actual_checksum |> Base.encode16() |> inspect() + + msg = "Expected SecretKeyPacket checksum to be #{expected_hex}, got #{actual_hex}. Maybe incorrect passphrase?" + + raise(msg) + end + end + + @s2k_spec_given_text "String-to-key specifier is being given" + defp s2k_usage_tuple(octet) when octet in 254..255, do: {octet, @s2k_spec_given_text} + defp s2k_usage_tuple(octet), do: Util.sym_algo_tuple(octet) +end diff --git a/lib/open_pgp/util.ex b/lib/open_pgp/util.ex new file mode 100644 index 0000000..8d37ddf --- /dev/null +++ b/lib/open_pgp/util.ex @@ -0,0 +1,266 @@ +defmodule OpenPGP.Util do + @moduledoc """ + Provides a set of utility functions to work with data. + """ + + alias OpenPGP.Packet + alias OpenPGP.Packet.BodyChunk, as: BChunk + + @type public_key_algo_tuple :: {1..255, binary()} + @type sym_algo_tuple :: {byte(), binary()} + @type compression_algo_tuple :: {byte(), binary()} + + @doc """ + Decode Multiprecision integer (MPI) given input binary. + Return MPI value and remaining binary. + + ## [RFC4880](https://www.ietf.org/rfc/rfc4880.txt) + + ### 3.2. Multiprecision Integers + + Multiprecision integers (also called MPIs) are unsigned integers used + to hold large integers such as the ones used in cryptographic + calculations. + + An MPI consists of two pieces: a two-octet scalar that is the length + of the MPI in bits followed by a string of octets that contain the + actual integer. + + These octets form a big-endian number; a big-endian number can be + made into an MPI by prefixing it with the appropriate length. + + Examples: + + (all numbers are in hexadecimal) + + The string of octets [00 01 01] forms an MPI with the value 1. The + string [00 09 01 FF] forms an MPI with the value of 511. + + Additional rules: + + The size of an MPI is ((MPI.length + 7) / 8) + 2 octets. + + The length field of an MPI describes the length starting from its + most significant non-zero bit. Thus, the MPI [00 02 01] is not + formed correctly. It should be [00 01 01]. + + Unused bits of an MPI MUST be zero. + + Also note that when an MPI is encrypted, the length refers to the + plaintext MPI. It may be ill-formed in its ciphertext. + """ + @spec decode_mpi(<<_::16, _::_*8>>) :: {mpi_value :: binary(), rest :: binary()} + def decode_mpi(<>) do + octets_count = floor((mpi_length + 7) / 8) + <> = rest + + {mpi_value, next} + end + + @doc """ + Invers of `.decode_mpi/1`. Takes an MPI value, and encode it as MPI + binary. + + ### Example: + + iex> OpenPGP.Util.encode_mpi(<<0x1>>) + <<0, 0x1, 0x1>> + + iex> OpenPGP.Util.encode_mpi(<<0x1, 0xFF>>) + <<0x0, 0x9, 0x1, 0xFF>> + """ + @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) + + <> + end + + @doc """ + Concatenates packet body given a Packet or a list of BodyChunks. + """ + @spec concat_body([BChunk.t()] | Packet.t()) :: bitstring() + def concat_body(%Packet{body: chunks}) when is_list(chunks), do: do_concat_body(chunks, "") + def concat_body(chunks) when is_list(chunks), do: do_concat_body(chunks, "") + defp do_concat_body([%BChunk{data: data} | rest], acc), do: do_concat_body(rest, acc <> data) + defp do_concat_body([], acc), do: acc + + @public_key_algos %{ + 1 => "RSA (Encrypt or Sign) [HAC]", + 2 => "RSA Encrypt-Only [HAC]", + 3 => "RSA Sign-Only [HAC]", + 16 => "Elgamal (Encrypt-Only) [ELGAMAL] [HAC]", + 17 => "DSA (Digital Signature Algorithm) [FIPS186] [HAC]", + 18 => "ECDH public key algorithm", + 19 => "ECDSA public key algorithm [FIPS186]", + 20 => "Reserved (formerly Elgamal Encrypt or Sign)", + 21 => "Reserved for Diffie-Hellman (X9.42, as defined for IETF-S/MIME)", + 22 => "EdDSA [RFC8032]", + 23 => "Reserved for AEDH", + 24 => "Reserved for AEDSA", + 100 => "Private/Experimental algorithm", + 101 => "Private/Experimental algorithm", + 102 => "Private/Experimental algorithm", + 103 => "Private/Experimental algorithm", + 104 => "Private/Experimental algorithm", + 105 => "Private/Experimental algorithm", + 106 => "Private/Experimental algorithm", + 107 => "Private/Experimental algorithm", + 108 => "Private/Experimental algorithm", + 109 => "Private/Experimental algorithm", + 110 => "Private/Experimental algorithm" + } + @public_key_ids Map.keys(@public_key_algos) + + @doc """ + Convert public-key algorithm ID to a tuple with ID and name binary. + + --- + + RFC4880 (https://www.ietf.org/rfc/rfc4880.txt) + + 9.1. Public-Key Algorithms + +-----------+----------------------------------------------------+ + | ID | Algorithm | + +-----------+----------------------------------------------------+ + | 1 | RSA (Encrypt or Sign) [HAC] | + | 2 | RSA Encrypt-Only [HAC] | + | 3 | RSA Sign-Only [HAC] | + | 16 | Elgamal (Encrypt-Only) [ELGAMAL] [HAC] | + | 17 | DSA (Digital Signature Algorithm) [FIPS186] [HAC] | + | 18 | ECDH public key algorithm | + | 19 | ECDSA public key algorithm [FIPS186] | + | 20 | Reserved (formerly Elgamal Encrypt or Sign) | + | 21 | Reserved for Diffie-Hellman | + | | (X9.42, as defined for IETF-S/MIME) | + | 22 | EdDSA [RFC8032] | + | 23 | Reserved for AEDH | + | 24 | Reserved for AEDSA | + | 100--110 | Private/Experimental algorithm | + +-----------+----------------------------------------------------+ + """ + @spec public_key_algo_tuple(1..255) :: public_key_algo_tuple() + def public_key_algo_tuple(algo) when algo in @public_key_ids, + do: {algo, @public_key_algos[algo]} + + @sym_algos %{ + 0 => {"Plaintext or unencrypted data", 0, 0}, + 1 => {"IDEA [IDEA]", 64, 128}, + 2 => {"TripleDES (DES-EDE, [SCHNEIER] [HAC] - 168 bit key derived from 192)", 64, 192}, + 3 => {"CAST5 (128 bit key, as per [RFC2144])", 64, 128}, + 4 => {"Blowfish (128 bit key, 16 rounds) [BLOWFISH]", 64, 128}, + 5 => {"Reserved", nil, nil}, + 6 => {"Reserved", nil, nil}, + 7 => {"AES with 128-bit key [AES]", 128, 128}, + 8 => {"AES with 192-bit key", 128, 192}, + 9 => {"AES with 256-bit key", 128, 256}, + 10 => {"Twofish with 256-bit key [TWOFISH]", 128, 256}, + 11 => {"Camellia with 128-bit key [RFC3713]", 128, 128}, + 12 => {"Camellia with 192-bit key", 128, 192}, + 13 => {"Camellia with 256-bit key", 128, 256}, + 100 => {"Private/Experimental algorithm", nil, nil}, + 101 => {"Private/Experimental algorithm", nil, nil}, + 102 => {"Private/Experimental algorithm", nil, nil}, + 103 => {"Private/Experimental algorithm", nil, nil}, + 104 => {"Private/Experimental algorithm", nil, nil}, + 105 => {"Private/Experimental algorithm", nil, nil}, + 106 => {"Private/Experimental algorithm", nil, nil}, + 107 => {"Private/Experimental algorithm", nil, nil}, + 108 => {"Private/Experimental algorithm", nil, nil}, + 109 => {"Private/Experimental algorithm", nil, nil}, + 110 => {"Private/Experimental algorithm", nil, nil} + } + @sym_algo_ids Map.keys(@sym_algos) + + @doc """ + Convert symmetric encryption algorithm ID to a tuple with ID and name binary. + + --- + + RFC4880 (https://www.ietf.org/rfc/rfc4880.txt) + + 9.3. Symmetric-Key Algorithms + +-----------+-----------------------------------------------+ + | ID | Algorithm | + +-----------+-----------------------------------------------+ + | 0 | Plaintext or unencrypted data | + | 1 | IDEA [IDEA] | + | 2 | TripleDES (DES-EDE, [SCHNEIER] [HAC] | + | | - 168 bit key derived from 192) | + | 3 | CAST5 (128 bit key, as per [RFC2144]) | + | 4 | Blowfish (128 bit key, 16 rounds) [BLOWFISH] | + | 5 | Reserved | + | 6 | Reserved | + | 7 | AES with 128-bit key [AES] | + | 8 | AES with 192-bit key | + | 9 | AES with 256-bit key | + | 10 | Twofish with 256-bit key [TWOFISH] | + | 11 | Camellia with 128-bit key [RFC3713] | + | 12 | Camellia with 192-bit key | + | 13 | Camellia with 256-bit key | + | 100--110 | Private/Experimental algorithm | + +-----------+-----------------------------------------------+ + """ + @spec sym_algo_tuple(byte()) :: sym_algo_tuple() + def sym_algo_tuple(algo) when algo in @sym_algo_ids, do: {algo, elem(@sym_algos[algo], 0)} + + @doc """ + Detects cipher block size (bits) given symmetric encryption algorithm ID or a tuple. + """ + @spec sym_algo_cipher_block_size(byte() | sym_algo_tuple()) :: non_neg_integer() + def sym_algo_cipher_block_size({algo, _}), do: sym_algo_cipher_block_size(algo) + def sym_algo_cipher_block_size(algo) when algo in @sym_algo_ids, do: elem(@sym_algos[algo], 1) + + @doc """ + Detects cipher key size (bits) given symmetric encryption algorithm ID or a tuple. + """ + @spec sym_algo_key_size(byte() | sym_algo_tuple()) :: non_neg_integer() + def sym_algo_key_size({algo, _}), do: sym_algo_key_size(algo) + def sym_algo_key_size(algo) when algo in @sym_algo_ids, do: elem(@sym_algos[algo], 2) + + @comp_algos %{ + 0 => "Uncompressed", + 1 => "ZIP [RFC1951]", + 2 => "ZLIB [RFC1950]", + 3 => "BZip2 [BZ2]", + 100 => "Private/Experimental algorithm", + 101 => "Private/Experimental algorithm", + 102 => "Private/Experimental algorithm", + 103 => "Private/Experimental algorithm", + 104 => "Private/Experimental algorithm", + 105 => "Private/Experimental algorithm", + 106 => "Private/Experimental algorithm", + 107 => "Private/Experimental algorithm", + 108 => "Private/Experimental algorithm", + 109 => "Private/Experimental algorithm", + 110 => "Private/Experimental algorithm" + } + @comp_algo_ids Map.keys(@comp_algos) + + @doc """ + Convert compression algorithm ID to a tuple with ID and name binary. + + --- + + RFC4880 (https://www.ietf.org/rfc/rfc4880.txt) + + 9.3. Compression Algorithms + + ID Algorithm + -- --------- + 0 - Uncompressed + 1 - ZIP [RFC1951] + 2 - ZLIB [RFC1950] + 3 - BZip2 [BZ2] + 100 to 110 - Private/Experimental algorithm + """ + @spec compression_algo_tuple(byte()) :: compression_algo_tuple() + def compression_algo_tuple(algo) when algo in @comp_algo_ids, do: {algo, @comp_algos[algo]} +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..3907c37 --- /dev/null +++ b/mix.exs @@ -0,0 +1,93 @@ +defmodule OpenPGP.MixProject do + use Mix.Project + + @source_url "https://github.com/DivvyPayHQ/open_pgp" + @version "0.5.0" + @description "OpenPGP Message Format in Elixir - RFC4880" + + def project() do + [ + app: :open_pgp, + version: @version, + elixir: "~> 1.13", + build_embedded: Mix.env() == :prod, + start_permanent: Mix.env() == :prod, + description: @description, + package: package(), + deps: deps(), + name: "OpenPGP", + source_url: @source_url, + consolidate_protocols: Mix.env() != :test, + elixirc_paths: elixirc_paths(Mix.env()), + dialyzer: dialyzer(), + preferred_cli_env: [docs: :docs], + docs: docs() + ] + end + + def application() do + [ + extra_applications: [:crypto] + ] + end + + defp package do + [ + maintainers: ["Pavel Tsiukhtsiayeu"], + licenses: ["MIT"], + links: %{"GitHub" => @source_url}, + source_url: @source_url, + files: ["lib", "*.exs", "*.md"] + ] + end + + defp docs() do + [ + source_ref: "v#{@version}", + source_url: @source_url, + canonical: "http://hexdocs.pm/open_pgp", + main: "readme", + name: "OpenPGP", + extras: ["README.md", "LICENSE.md", "CODE_OF_CONDUCT.md", "CHANGELOG.md"], + groups_for_modules: [ + "Generic Packet": [ + OpenPGP.Packet, + OpenPGP.Packet.BodyChunk, + OpenPGP.Packet.PacketTag + ], + "Tag Specific Packets": [ + OpenPGP.CompressedDataPacket, + OpenPGP.IntegrityProtectedDataPacket, + OpenPGP.LiteralDataPacket, + OpenPGP.PublicKeyEncryptedSessionKeyPacket, + OpenPGP.PublicKeyPacket, + OpenPGP.SecretKeyPacket + ], + "Radix-64": [ + OpenPGP.Radix64, + OpenPGP.Radix64.CRC24, + OpenPGP.Radix64.Entry + ] + ] + ] + end + + defp deps() do + [ + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, + {:ex_doc, "~> 0.30", only: [:docs], runtime: false} + ] + end + + defp dialyzer() do + [ + ignore_warnings: ".dialyzer_ignore.exs", + list_unused_filters: true, + plt_add_apps: [:mix, :ex_unit] + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..45fb723 --- /dev/null +++ b/mix.lock @@ -0,0 +1,14 @@ +%{ + "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.31.1", "8a2355ac42b1cc7b2379da9e40243f2670143721dd50748bf6c3b1184dae2089", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "3178c3a407c557d8343479e1ff117a96fd31bafe52a039079593fb0524ef61b0"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "makeup": {:hex, :makeup, "1.1.1", "fa0bc768698053b2b3869fa8a62616501ff9d11a562f3ce39580d60860c3a55e", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5dc62fbdd0de44de194898b6710692490be74baa02d9d108bc29f007783b0b48"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.4", "29563475afa9b8a2add1b7a9c8fb68d06ca7737648f28398e04461f008b69521", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f4ed47ecda66de70dd817698a703f8816daa91272e7e45812469498614ae8b29"}, + "mix_audit": {:hex, :mix_audit, "2.1.2", "6cd5c5e2edbc9298629c85347b39fb3210656e541153826efd0b2a63767f3395", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "68d2f06f96b9c445a23434c9d5f09682866a5b4e90f631829db1c64f140e795b"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"}, +} diff --git a/test/fixtures/rsa2048-priv.armor.pgp b/test/fixtures/rsa2048-priv.armor.pgp new file mode 100644 index 0000000..201651e --- /dev/null +++ b/test/fixtures/rsa2048-priv.armor.pgp @@ -0,0 +1,59 @@ +-----BEGIN PGP PRIVATE KEY BLOCK----- + +lQPGBGWUT9gBCADdsbW+4+TstPCgiFMRJ0wl+aNDtEWOGGJG48DeaeBDOvZVmy9I +aq8Oq9MgblG3hehBrFLMEjt/TegOzUdOWHNZhIHJMKh71l4btiL1sq1mY4xlCZfx +BP7HpCRrwA5GEVLROpw0NExgOMrmvtid9+Fco68RzLKQtTDgCnjAJskRGfxtqgbH +5s1E2npsXQXpOE6R9/Co9IORRLMhvh7GNRFCIT+HG4gHM3/0r0aNoi57rJTFHOGy +myo5ccbk3HChJtKBKBXAgp9KMHKtT9tCmcoBpPwZluVrfOM8wyGau5sr4GN0i7Qo +ST4trU1FmnHoYrbizyPrROtVg/Hx/DhcGbH5ABEBAAH+BwMCi6z8WYPn1Qn94oXJ +9CbYcH/6c341PdiL+UyG0sPry/SZHuWZb/GNAxf+3rek9AnNGgzG1uAAz+gDaXsN +EQ0jRmB+TO8LoREPvhH1uvDJ95VmJbl1tRlju8zz2eb5CM6R34MqmbFjB7lPjDMJ +Iw2oLvxjtt1SJxXULU0/GJXw0Tn/3n/zMowG2Rr/W+XE0s8dzrfCbO8WOdwEsHKt +kxhE+oaPdlDwnZ7Yd1VKXW4CvUytnb1Wi2EB5IKt/9aa0UEUFaVEyC6S/RAHuCzF +7HXw66dPclnDLYb7gatxSdkGVQO9/raKjeD0zlcGniTritlO0G0dDca3WJCMBbdj +lmJ70UxecJYI8XtnSK+a821qN9VTBbMJZgEXXJGAfkyiXnN9LKncqpV5K0gPhzpX +5UWk/e7t4WH9FAW31+FQgP0bNZu4PbJkuCDyMrYPDvRS3HJmtNcn3c2fNwmC7py9 +MZpcoe3PLtb82DdEfeh6Xyh42WmUve9tbhQbk+CfDFkjtqxqL57Q9S2OMKPIb1qq +ossBwSBT/x3wEM8GnTxYbYJ3KN7cU0o344UbRww+2nfre9AoANN1gEMnGYlJFS2y +e437nsaO7meLfOUE2v7aSnBH3mWCy4+AN3RQmPv9MkQTpmQpJEw22T6HzNHZGFe+ +8B1eBbHkuzzE+f4cCBQGSQh773WVPxUvuBDMyCToBQIckoJFzXeI414ZoqFNmj4r +w0julIn/L14VrtCHZlJeuKHIFESIoVrfvjcuWma+YF4u/19GDCILiR3ripTgBetc +hxYX3v9skP+lSKHezI5Dk0zQcHajFPkY3DkN/my5BtpL3tibD+vuoWFJr8Vyod+H +UsuKMw8zsqPBj+2YEiYguUNDRG78rx47xJGkYG1OGrJX3mY+XKWyMDQWPckVwVL6 +nf8O8uSJ9JYWtClKb2huIERvZSAoUlNBMjA0OCkgPGpvaG4uZG9lQGV4YW1wbGUu +Y29tPokBTgQTAQgAOBYhBPibZPeCJUsDYk/PXAUug4G1wzXaBQJllE/YAhsDBQsJ +CAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEAUug4G1wzXabKkH/RE6mSkLCuewHu44 +vIWbbnGZX48CVsTRQx/BhcnPurYj5WOkDG5gY+U8t59aFCi0CZg7c78miPSgjDFD +vFJQ7Vta52sde8MdI4HyYVQ+I39K/F5s8O1HXCiR1KLOW6uUgyh3uvPfjvREyOxp +DfhJ2F/rOW38LB6YQ97Re/BVwhty+zpfx5NPFa8Rq62bAJrGcPI/eNbLzXkrhYwa +wGuakASS/IUgP4pHI5zQ+HdtniFwYKQGLrGlRL5g6MdvQy0qEo8B84/e3v/GnW5q +C9ibhKzfgpeIvZ8utqbMx4IbFDDoO3mZvmjuXaFSePDYUw1LGZsHUconqPvNIZl3 +izbbQo2dA8YEZZX0kwEIAKNt4Ol5muFrrP5gEZwEAWE9pKUT3VlurdSdyn2GTxW0 +P+k23+25j4x4C7ByJwq7wcLUjgvK98YIzQexT856taz4Vqy+fhrb7sgIc2c7oPr2 +p5+SuJX91F7GId5gyx4zWlNEDd59fGGLLj5eavQcHWklWssDPuwg1y3sbvduXsa9 +Lkbcnv4ARw+mPCVrKx4ySkxczLTvycV52oU1BD3jONM4Mfn7dpy19TJufpn9mbnk +bmgqHkgfE9DQyyD9lGXUXomwawoD6QKKin6RsjODquKm+UDDe/187DVLVuobIoAw +JOPdtvtUBQQmjtfoZ3JcXRuiXcA9FB0IhRnpyqI9KLcAEQEAAf4HAwLa4s0YghRQ +e/0qUmW9qi1KEmWOUW/abLlonykFrjzspRfBEXQTmXSSYg+GGHJkO27GGFDMaCzd +vtVZPAwfxjSNo4Ufi+gDpCyhBzeFjSGQCOVVfG5O3QqQw03eaPcxiwpK0p60hUcf +2DdDMYcDNTfWikxxcmUycx2hcrVlYgx6qVWDKQgDUhZS7+YOVUErUcyfzhYlyPx3 +FpH/rFUVpu0zfRHvuuPCuCoT/vITEw+mHwZqBBleIUCipjMB5oKzFVre0UWYdEad +6jFNB6avDeE1njKEWWBpHrIYuz3LBW/FbiTW8klkC50xouBf5WpYl1X1MDJMQfDL +3qZWU2syUd/eheYF6XI/GR2sq8S0K62WVBnDkU62/5OGQQUdGEyGQeCjewri03tP +tOdllqHd+agkwQrsjIfq5ZMJP5QCqsg2RRHJeDHlUZDADnkHu4jtONvsVVSh3rrf +gTwUUbhSmDpTaE2ra2151/7u56e2dSnt9D89kwtZqpO+ewqqzUZ+NGcOckd2YN0q +SfGR37T+AsyM6UsjxqC0ZrV2yKwBPTOHEmwPRYkqhAxmfPrVSSeruMHdqWQbpFTH +gOSM5Iuv+3Xt9Ls96x+JmvFvd3C2Cc04qgSLj2zGMOSujUD7gclhZkB93A1QWLuP +ScG6mkEx0qJtGS+zci+EeYHgKPjERpqLJE3MTxjUr3Y9/GwExU5hdfSseBQii04c +3PcFlXJId/JLJwDk56RgrRRC6PKUyYXJHeEWnZt0iXvLfrlshvEM+02Ywa99sxH4 +TIaZ5GvvNyQZyjEMCGmJdXCboNcO+fs4WDz1tlQlWDDnYCEfD7xMDJ1GdgBGiNt8 +8+WbJbbgmg7s8+euoC+eVZFpKnjTDqSVyghFCRGs3Kie8dzhemdGoHe9mX9vl264 +oM/BCFUnUcGgvT2HmjeJATYEGAEIACAWIQT4m2T3giVLA2JPz1wFLoOBtcM12gUC +ZZX0kwIbDAAKCRAFLoOBtcM12p0aB/9lo+0Am2dhS13kf72tBwSdXJxeXvlkj7+n +8LL+7HjRn9WdIS64+Qj7/xv1Ud0iF1B/ovT1xmJ5SR6XPfN6sLFDBV+GI4CFLnEQ +7U9ArpVcx0ggtLdOHwjBkX7ae0BV8IqRmvXaOLuASd/LdRgpWQ8F113/kK0+ex5l +TlH+JktRsJuBH35K0l3CjNAgH1loHnq2gZtegJxlXPjX5oYFl3UyAIK6bF/ULBoS +g9ZalhlUuk5RmJ93PzR+ow8V3uoWPam6Ku9l4fIX9tcGrS8rYSgh6Z7pJzVGZJ5z +11OOuI3P6T9xJkKoyf2k4KVwCjb+47TAFcxauFYYlAp+Szfeqoi2 +=8wmL +-----END PGP PRIVATE KEY BLOCK----- diff --git a/test/fixtures/rsa2048-priv.pgp b/test/fixtures/rsa2048-priv.pgp new file mode 100644 index 0000000..6908e12 Binary files /dev/null and b/test/fixtures/rsa2048-priv.pgp differ diff --git a/test/fixtures/words.dict.gpg b/test/fixtures/words.dict.gpg new file mode 100644 index 0000000..31cec9a Binary files /dev/null and b/test/fixtures/words.dict.gpg differ diff --git a/test/open_pgp/compressed_data_packet_test.exs b/test/open_pgp/compressed_data_packet_test.exs new file mode 100644 index 0000000..0b9d497 --- /dev/null +++ b/test/open_pgp/compressed_data_packet_test.exs @@ -0,0 +1,36 @@ +defmodule OpenPGP.CompressedDataPacketTest do + use OpenPGP.Test.Case, async: true + doctest OpenPGP.CompressedDataPacket + + alias OpenPGP.CompressedDataPacket + + @deflated <<1, 59, 109, 150, 196, 17, 239, 236, 239, 23, 236, 239, 227, 202, 0, 4, 206, 137, 121, 234, 37, 10, 137, + 121, 149, 249, 121, 169, 10, 217, 169, 169, 5, 10, 137, 10, 197, 169, 201, 69, 169, 64, 193, 162, 252, + 210, 188, 20, 133, 140, 212, 162, 84, 123, 46, 0>> + test "inflate ZIP compressed packet" do + assert {%OpenPGP.CompressedDataPacket{ + algo: {1, "ZIP [RFC1951]"}, + data_deflated: <<59, 109, 150, 196, 17, _::binary>>, + data_inflated: <<203, 54, 98, 8, 95, 67, 79, 78, _::binary>> + }, <<>>} = CompressedDataPacket.decode(@deflated) + end + + @deflated <<2, 120, 156, 243, 72, 205, 201, 201, 215, 81, 8, 207, 47, 202, 73, 81, 84, 84, 4, 0, 40, 213, 4, 172>> + test "inflate ZLIB compressed packet" do + assert { + %CompressedDataPacket{ + algo: {2, "ZLIB [RFC1950]"}, + data_deflated: <<120, 156, 243, 72, 205, _::binary>>, + data_inflated: "Hello, World!!!" + }, + <<>> + } = CompressedDataPacket.decode(@deflated) + end + + @deflated <<0, 120, 156, 243, 72, 205, 201, 201, 215, 81, 8, 207, 47, 202, 73, 81, 84, 84, 4, 0, 40, 213, 4, 172>> + test "raises error if not supported algo (Uncompressed)" do + assert_raise RuntimeError, ~r/Unsupported compression algo {0, "Uncompressed"}. As of 0.5.x/, fn -> + CompressedDataPacket.decode(@deflated) + end + end +end diff --git a/test/open_pgp/integrity_protected_data_packet_test.exs b/test/open_pgp/integrity_protected_data_packet_test.exs new file mode 100644 index 0000000..6e47ef9 --- /dev/null +++ b/test/open_pgp/integrity_protected_data_packet_test.exs @@ -0,0 +1,4 @@ +defmodule OpenPGP.IntegrityProtectedDataPacketTest do + use OpenPGP.Test.Case, async: true + doctest OpenPGP.IntegrityProtectedDataPacket +end diff --git a/test/open_pgp/literal_data_packet_test.exs b/test/open_pgp/literal_data_packet_test.exs new file mode 100644 index 0000000..9a5a8b6 --- /dev/null +++ b/test/open_pgp/literal_data_packet_test.exs @@ -0,0 +1,4 @@ +defmodule OpenPGP.LiteralDataPacketTest do + use OpenPGP.Test.Case, async: true + doctest OpenPGP.LiteralDataPacket +end diff --git a/test/open_pgp/packet/body_chunk_test.exs b/test/open_pgp/packet/body_chunk_test.exs new file mode 100644 index 0000000..250650b --- /dev/null +++ b/test/open_pgp/packet/body_chunk_test.exs @@ -0,0 +1,172 @@ +defmodule OpenPGP.Packet.BodyChunkTest do + use OpenPGP.Test.Case, async: true + doctest OpenPGP.Packet.BodyChunk + + alias OpenPGP.Packet.BodyChunk, as: BChunk + alias OpenPGP.Packet.PacketTag, as: PacketTag + + describe ".decode/2" do + test "decodes one-octet length Old Format Packet Length Header" do + packet_tag = %PacketTag{format: :old, length_type: {0, "one-octet"}} + input = <<5::8, "Hello world!">> + + assert {%BChunk{data: "Hello", header_length: 1, chunk_length: {:fixed, 5}}, " world!"} = + BChunk.decode(input, packet_tag) + end + + test "decodes two-octet length Old Format Packet Length Header" do + # We want two octets body length: 255 + 5 = 260 + # Generate 255 random octets and prepend with "Hello world!" + + packet_tag = %PacketTag{format: :old, length_type: {1, "two-octet"}} + rand_bytes = :crypto.strong_rand_bytes(255) + input = <<260::16, "Hello world!", rand_bytes::binary>> + + assert {%BChunk{data: data, header_length: 2, chunk_length: {:fixed, blen}}, rest} = + BChunk.decode(input, packet_tag) + + assert blen == 260 + assert byte_size(data) == 260 + assert "Hello world!" <> pt1 = data + assert pt1 <> rest == rand_bytes + end + + test "decodes four-octet length Old Format Packet Length Header" do + # We want four octets body length: (0xFF << 24) + (0xFF << 16) + (0xFF << 8) + 0x05= 0xFFFFFF05 + # It does not make sense to generate 0xFFFFFF05 random octets in test due to limited memory (~4Gb) + # In general BodyChunk should blindly trust :chunk_length property, and take that many bytes. + # As long as we use 4 octets to encode the chunk length, i.e. <<260::32>> + + packet_tag = %PacketTag{format: :old, length_type: {2, "four-octet"}} + rand_bytes = :crypto.strong_rand_bytes(255) + input = <<260::32, "Hello world!", rand_bytes::binary>> + + assert {%BChunk{data: data, header_length: 4, chunk_length: {:fixed, blen}}, rest} = + BChunk.decode(input, packet_tag) + + assert blen == 260 + assert byte_size(data) == 260 + assert "Hello world!" <> pt1 = data + assert pt1 <> rest == rand_bytes + end + + test "decodes indeterminate length Old Format Packet Length Header" do + # Per RFC 4880: + # If the packet is in a file, this means that the packet extends until + # the end of the file. + # For test we will use five-octets long body. + + packet_tag = %PacketTag{format: :old, length_type: {3, "indeterminate"}} + input = "Hello world!" + + assert {%BChunk{data: data, header_length: 0, chunk_length: {:indeterminate, blen}}, rest} = + BChunk.decode(input, packet_tag) + + assert blen == byte_size(input) + assert "Hello world!" = data + assert "" = rest + end + + test "decodes one-octet length New Format Packet Length Header (up to 191 octets)" do + # Per RFC4880: + # A one-octet Body Length header encodes a length of 0 to 191 octets. + # This type of length header is recognized because the one octet value + # is less than 192. The body length is equal to: + + # bodyLen = 1st_octet; + + packet_tag = %PacketTag{format: :new} + input = <<5::8, "Hello world!">> + + assert {%BChunk{data: "Hello", header_length: 1, chunk_length: {:fixed, 5}}, " world!"} = + BChunk.decode(input, packet_tag) + end + + test "decodes two-octet length New Format Packet Length Header (192-8383 octets)" do + # Per RFC4880: + # A two-octet Body Length header encodes a length of 192 to 8383 + # octets. It is recognized because its first octet is in the range 192 + # to 223. The body length is equal to: + # + # bodyLen = ((1st_octet - 192) << 8) + (2nd_octet) + 192 + + # We want two octets body length: ((192 - 192) << 8) + (68) + 192 = 260 + # That should be encoded as: <<192::8, 68::8>> + + packet_tag = %PacketTag{format: :new} + rand_bytes = :crypto.strong_rand_bytes(255) + input = <<192::8, 68::8, "Hello world!", rand_bytes::binary>> + + assert {%BChunk{data: data, header_length: 2, chunk_length: {:fixed, blen}}, rest} = + BChunk.decode(input, packet_tag) + + assert blen == 260 + assert byte_size(data) == 260 + assert "Hello world!" <> pt1 = data + assert pt1 <> rest == rand_bytes + end + + test "decodes five-octet length New Format Packet Length Header (up to 4,294,967,295 octets)" do + # Per RFC4880: + # A five-octet Body Length header consists of a single octet holding + # the value 255, followed by a four-octet scalar. The body length is + # equal to: + + # bodyLen = (2nd_octet << 24) | (3rd_octet << 16) | + # (4th_octet << 8) | 5th_octet + + # We want encode 260 in five octets as <<255::8, 0::8, 0::8, 260::16>> + + packet_tag = %PacketTag{format: :new} + rand_bytes = :crypto.strong_rand_bytes(255) + input = <<255::8, 0::8, 0::8, 260::16, "Hello world!", rand_bytes::binary>> + + assert {%BChunk{data: data, header_length: 5, chunk_length: {:fixed, blen}}, rest} = + BChunk.decode(input, packet_tag) + + assert blen == 260 + assert byte_size(data) == 260 + assert "Hello world!" <> pt1 = data + assert pt1 <> rest == rand_bytes + end + + test "decodes partial body length New Format Packet Length Header (2 to the 30th power)" do + # Per RFC4880: + # A Partial Body Length header is one octet long and encodes the length + # of only part of the data packet. This length is a power of 2, from 1 + # to 1,073,741,824 (2 to the 30th power). It is recognized by its one + # octet value that is greater than or equal to 224, and less than 255. + # The Partial Body Length is equal to: + + # partialBodyLen = 1 << (1st_octet & 0x1F); + + # To understand this case, we need to look at 224-255 on a bit level: + + # 0x1F = 0b00011111 + # 224 = 0b11100000 + # 254 = 0b11111110 (255 is taken by five-octet length header) + + # The partial length header has all ones in the three most significant bits. + # Then, whatever number we have in the five least significant bits will be + # the power of two, according to the formula `1 << (1st_octet & 0x1F)`. + + # Example: + + # The encoded body length of 64 (2**6) in one octet partial length header on + # a bit level will be <<0b11100110::8>> (which is 230). + # To verify: `1 << (230 & 0x1F) = 64` + + packet_tag = %PacketTag{format: :new} + rand_bytes = :crypto.strong_rand_bytes(255) + input = <<0b11100110::8, "Hello world!", rand_bytes::binary>> + + assert {%BChunk{data: data, header_length: 1, chunk_length: {:partial, blen}}, rest} = + BChunk.decode(input, packet_tag) + + assert blen == 64 + assert byte_size(data) == 64 + assert "Hello world!" <> pt1 = data + assert pt1 <> rest == 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 new file mode 100644 index 0000000..e7f7fa1 --- /dev/null +++ b/test/open_pgp/packet/packet_tag_test.exs @@ -0,0 +1,24 @@ +defmodule OpenPGP.Packet.PacketTagTest do + use OpenPGP.Test.Case, async: true + doctest OpenPGP.Packet.PacketTag + + alias OpenPGP.Packet.PacketTag + + describe ".decode/1" do + test "decodes old packet format" do + assert {%PacketTag{ + format: :old, + tag: {5, "Secret-Key Packet"}, + length_type: {1, "two-octet"} + }, ""} = PacketTag.decode(<<1::1, 0::1, 5::4, 1::2>>) + end + + test "decodes new packet format" do + assert {%PacketTag{ + format: :new, + tag: {5, "Secret-Key Packet"}, + length_type: nil + }, ""} = PacketTag.decode(<<1::1, 1::1, 5::6>>) + end + end +end diff --git a/test/open_pgp/packet_test.exs b/test/open_pgp/packet_test.exs new file mode 100644 index 0000000..bedd172 --- /dev/null +++ b/test/open_pgp/packet_test.exs @@ -0,0 +1,4 @@ +defmodule OpenPGP.PacketTest do + use OpenPGP.Test.Case, async: true + doctest 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 new file mode 100644 index 0000000..4b546b2 --- /dev/null +++ b/test/open_pgp/public_key_encrypted_session_key_packet_test.exs @@ -0,0 +1,66 @@ +defmodule OpenPGP.PublicKeyEncryptedSessionKeyPacketTest do + use OpenPGP.Test.Case, async: true + + alias OpenPGP.Packet + alias OpenPGP.Packet.PacketTag + alias OpenPGP.PublicKeyEncryptedSessionKeyPacket + alias OpenPGP.SecretKeyPacket + alias OpenPGP.Util + + @rsa2048_priv File.read!("test/fixtures/rsa2048-priv.pgp") + @encrypted_file File.read!("test/fixtures/words.dict.gpg") + + describe ".decode/1" do + test "decodes packet and assignes ciphertext" do + # The Symmetrically Encrypted Data Packet is preceded by one + # Public-Key Encrypted Session Key packet for each OpenPGP key to + # which the message is encrypted. The recipient of the message + # finds a session key that is encrypted to their public key, + # decrypts the session key, and then uses the session key to + # decrypt the message. + assert [packet | _] = OpenPGP.list_packets(@encrypted_file) + + assert {packet, ""} = packet |> Util.concat_body() |> PublicKeyEncryptedSessionKeyPacket.decode() + + assert %PublicKeyEncryptedSessionKeyPacket{ + ciphertext: <<7, 255, 101, 61, 27, 178, 49, 190, 16, _::binary>>, + public_key_algo: {1, "RSA (Encrypt or Sign) [HAC]"}, + public_key_id: <<184, 5, 16, 71, 78, 123, 136, 254>>, + session_key_algo: nil, + session_key_material: nil, + version: 3 + } = packet + end + end + + describe ".decrypt/2" do + test "decrypts key material given a valid decrypted Secret-Key Packet" do + [ + %Packet{tag: %PacketTag{tag: {5, "Secret-Key Packet"}}}, + %Packet{tag: %PacketTag{tag: {13, "User ID Packet"}}}, + %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}}, + %Packet{tag: %PacketTag{tag: {7, "Secret-Subkey Packet"}}} = sk_packet, + %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}} + ] = OpenPGP.list_packets(@rsa2048_priv) + + {sk_packet_decoded, _} = + sk_packet + |> Util.concat_body() + |> SecretKeyPacket.decode() + + sk_packet_decrypted = SecretKeyPacket.decrypt(sk_packet_decoded, "passphrase") + + assert [packet | _] = OpenPGP.list_packets(@encrypted_file) + + assert {cipher_packet, ""} = packet |> Util.concat_body() |> PublicKeyEncryptedSessionKeyPacket.decode() + + assert %PublicKeyEncryptedSessionKeyPacket{ + session_key_material: session_key_material + } = PublicKeyEncryptedSessionKeyPacket.decrypt(cipher_packet, sk_packet_decrypted) + + assert {m_e_mod_n} = session_key_material + + assert "26A582ACA833B8EE60CC58865D19A21653D38CB0737125C9ABF973405E3B233C" = Base.encode16(m_e_mod_n) + end + end +end diff --git a/test/open_pgp/public_key_packet_test.exs b/test/open_pgp/public_key_packet_test.exs new file mode 100644 index 0000000..b58b8f1 --- /dev/null +++ b/test/open_pgp/public_key_packet_test.exs @@ -0,0 +1,36 @@ +defmodule OpenPGP.PublicKeyPacketTest do + use OpenPGP.Test.Case, async: true + alias OpenPGP.Packet + alias OpenPGP.Packet.PacketTag + alias OpenPGP.PublicKeyPacket + + @rsa2048_priv File.read!("test/fixtures/rsa2048-priv.pgp") + + test ".decode/1 decodes RSA Public-Key packet" do + assert [%Packet{body: chunks, tag: %PacketTag{tag: {5, "Secret-Key Packet"}}} | _] = + OpenPGP.list_packets(@rsa2048_priv) + + data = OpenPGP.Util.concat_body(chunks) + + assert {%PublicKeyPacket{ + algo: {1, "RSA (Encrypt or Sign) [HAC]"}, + created_at: ~U[2024-01-02 18:03:04Z], + expires: nil, + material: {mod_n, exp_e}, + version: 4 + }, rest} = PublicKeyPacket.decode(data) + + assert <<254, 7, 3, 2, 248, 49, 205, 223, 27, 66, 166, 109, _::binary>> = rest + + assert "DDB1B5BEE3E4ECB4F0A0885311274C25F9A343B4458E186246E3C0DE69E0433AF6559B2F4" <> + "86AAF0EABD3206E51B785E841AC52CC123B7F4DE80ECD474E5873598481C930A87BD65E" <> + "1BB622F5B2AD66638C650997F104FEC7A4246BC00E461152D13A9C34344C6038CAE6BED" <> + "89DF7E15CA3AF11CCB290B530E00A78C026C91119FC6DAA06C7E6CD44DA7A6C5D05E938" <> + "4E91F7F0A8F4839144B321BE1EC6351142213F871B8807337FF4AF468DA22E7BAC94C51" <> + "CE1B29B2A3971C6E4DC70A126D2812815C0829F4A3072AD4FDB4299CA01A4FC1996E56B" <> + "7CE33CC3219ABB9B2BE063748BB428493E2DAD4D459A71E862B6E2CF23EB44EB5583F1F" <> + "1FC385C19B1F9" == Base.encode16(mod_n) + + assert "010001" == Base.encode16(exp_e) + end +end diff --git a/test/open_pgp/radix64_test.exs b/test/open_pgp/radix64_test.exs new file mode 100644 index 0000000..a823286 --- /dev/null +++ b/test/open_pgp/radix64_test.exs @@ -0,0 +1,83 @@ +defmodule OpenPGP.Radix64Test do + use OpenPGP.Test.Case, async: true + doctest OpenPGP.Radix64.CRC24 + + alias OpenPGP.CompressedDataPacket + alias OpenPGP.LiteralDataPacket + alias OpenPGP.Packet + alias OpenPGP.Packet.PacketTag + alias OpenPGP.PublicKeyPacket + alias OpenPGP.Radix64 + alias OpenPGP.SecretKeyPacket + + describe ".decode/1" do + @payload """ + -----BEGIN PGP MESSAGE----- + Version: OpenPrivacy 0.99 + + yDgBO22WxBHv7O8X7O/jygAEzol56iUKiXmV+XmpCtmpqQUKiQrFqclFqUDBovzS + vBSFjNSiVHsuAA== + =njUN + -----END PGP MESSAGE----- + """ + test "reads armored payload" do + assert [ + %Radix64.Entry{ + crc: <<158, 53, 13>>, + data: <<200, 56, 1, 59, _::binary>> = data, + meta: [{"Version", "OpenPrivacy 0.99"}], + name: "PGP MESSAGE" + } + ] = Radix64.decode(@payload) + + assert [ + %CompressedDataPacket{ + algo: {1, "ZIP [RFC1951]"}, + data_deflated: _, + data_inflated: data_inflated + } + ] = data |> OpenPGP.list_packets() |> OpenPGP.cast_packets() + + assert [ + %LiteralDataPacket{ + created_at: ~U[1970-01-01 00:00:00Z], + data: "Can't anyone keep a secret around here?\n", + file_name: "_CONSOLE", + format: {"b", :binary} + } + ] = data_inflated |> OpenPGP.list_packets() |> OpenPGP.cast_packets() + end + + @payload File.read!("test/fixtures/rsa2048-priv.armor.pgp") + test "reads armored payload 2" do + assert [ + %Radix64.Entry{ + crc: <<243, 9, 139>>, + data: <<149, 3, 198, 4, 101, _::binary>> = data, + meta: [], + name: "PGP PRIVATE KEY BLOCK" + } + ] = Radix64.decode(@payload) + + assert [ + %SecretKeyPacket{ + public_key: %PublicKeyPacket{ + algo: {1, "RSA (Encrypt or Sign) [HAC]"}, + id: <<5, 46, 131, 129, 181, 195, 53, 218>>, + version: 4 + } + }, + %Packet{tag: %PacketTag{tag: {13, "User ID Packet"}}}, + %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}}, + %OpenPGP.SecretKeyPacket{ + public_key: %PublicKeyPacket{ + algo: {1, "RSA (Encrypt or Sign) [HAC]"}, + id: <<184, 5, 16, 71, 78, 123, 136, 254>>, + version: 4 + } + }, + %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}} + ] = data |> OpenPGP.list_packets() |> OpenPGP.cast_packets() + end + end +end diff --git a/test/open_pgp/s2k_specifier_test.exs b/test/open_pgp/s2k_specifier_test.exs new file mode 100644 index 0000000..7da9134 --- /dev/null +++ b/test/open_pgp/s2k_specifier_test.exs @@ -0,0 +1,93 @@ +# credo:disable-for-next-line CredoNaming.Check.Consistency.ModuleFilename +defmodule OpenPGP.S2KSpecifierTest do + use OpenPGP.Test.Case, async: true + alias OpenPGP.S2KSpecifier + + @salt "**SALT**" + + describe ".decode/1" do + test "decodes Simple S2K (ID: 0)" do + input = <<0::8, 2::8, "rest"::binary>> + + assert {%S2KSpecifier{ + id: {0, "Simple S2K"}, + algo: {2, "SHA-1 [FIPS180]"}, + protect_count: nil, + salt: nil + }, "rest"} = S2KSpecifier.decode(input) + end + + test "decodes Salted S2K (ID: 1)" do + input = <<1::8, 2::8, @salt, "rest"::binary>> + + assert {%S2KSpecifier{ + id: {1, "Salted S2K"}, + algo: {2, "SHA-1 [FIPS180]"}, + protect_count: nil, + salt: @salt + }, "rest"} = S2KSpecifier.decode(input) + end + + @coded_protect_count 252 + test "decodes Iterated and Salted S2K (ID: 3)" do + input = <<3::8, 2::8, @salt, @coded_protect_count::8, "rest"::binary>> + + assert {%S2KSpecifier{ + id: {3, "Iterated and Salted S2K"}, + algo: {2, "SHA-1 [FIPS180]"}, + protect_count: {@coded_protect_count, 58_720_256}, + salt: @salt + }, "rest"} = S2KSpecifier.decode(input) + end + + test "decodes Reserved value (ID: 2)" do + input = <<2::8, "rest"::binary>> + + assert {%S2KSpecifier{ + id: {2, "Reserved value"}, + algo: nil, + protect_count: nil, + salt: nil + }, "rest"} = S2KSpecifier.decode(input) + end + + test "decodes Private/Experimental S2K (ID: 105)" do + input = <<105::8, "rest"::binary>> + + assert {%S2KSpecifier{ + id: {105, "Private/Experimental S2K"}, + algo: nil, + protect_count: nil, + salt: nil + }, "rest"} = S2KSpecifier.decode(input) + end + end + + describe ".build_session_key/3" do + @s2k_specifier %S2KSpecifier{ + id: {3, "Iterated and Salted S2K"}, + algo: {2, "SHA-1 [FIPS180]"}, + protect_count: {252, 58_720_256}, + salt: @salt + } + + test "builds 128 bits session key" do + session_key = S2KSpecifier.build_session_key(@s2k_specifier, 128, "passphrase") + assert bit_size(session_key) == 128 + assert "D291546EBA8ECB8242048C10FED0BC81" = Base.encode16(session_key) + end + + test "builds 192 bits session key" do + session_key = S2KSpecifier.build_session_key(@s2k_specifier, 192, "passphrase") + assert bit_size(session_key) == 192 + assert "D291546EBA8ECB8242048C10FED0BC8156291EBDDB7ED000" = Base.encode16(session_key) + end + + test "builds 256 bits session key" do + session_key = S2KSpecifier.build_session_key(@s2k_specifier, 256, "passphrase") + assert bit_size(session_key) == 256 + + assert "D291546EBA8ECB8242048C10FED0BC8156291EBDDB7ED000EEBA925CF485D825" = Base.encode16(session_key) + end + end +end diff --git a/test/open_pgp/secret_key_packet_test.exs b/test/open_pgp/secret_key_packet_test.exs new file mode 100644 index 0000000..d705c4e --- /dev/null +++ b/test/open_pgp/secret_key_packet_test.exs @@ -0,0 +1,109 @@ +defmodule OpenPGP.SecretKeyPacketTest do + use OpenPGP.Test.Case, async: true + alias OpenPGP.Packet + alias OpenPGP.Packet.PacketTag + alias OpenPGP.PublicKeyPacket + alias OpenPGP.S2KSpecifier + alias OpenPGP.SecretKeyPacket + alias OpenPGP.Util + + @rsa2048_priv File.read!("test/fixtures/rsa2048-priv.pgp") + + describe ".decode/1" do + test "decodes Secret-Key Packet and assignes ciphertext" do + assert [ + %Packet{tag: %PacketTag{tag: {5, "Secret-Key Packet"}}} = sk_packet + | _ + ] = OpenPGP.list_packets(@rsa2048_priv) + + assert {%SecretKeyPacket{} = sk_packet_decoded, <<>>} = + sk_packet + |> Util.concat_body() + |> SecretKeyPacket.decode() + + assert %SecretKeyPacket{ + public_key: %PublicKeyPacket{ + algo: {1, "RSA (Encrypt or Sign) [HAC]"}, + version: 4 + }, + s2k_usage: {254, "String-to-key specifier is being given"}, + s2k_specifier: %S2KSpecifier{ + algo: {2, "SHA-1 [FIPS180]"}, + id: {3, "Iterated and Salted S2K"}, + protect_count: {252, 58_720_256}, + salt: s2k_salt + }, + sym_key_algo: {7, "AES with 128-bit key [AES]"}, + sym_key_initial_vector: sym_key_iv, + sym_key_size: 128, + ciphertext: <<122, 114, 71, _::binary>>, + secret_key_material: nil + } = sk_packet_decoded + + assert "F831CDDF1B42A66D" = Base.encode16(s2k_salt) + assert "36EB1D1342D9F9E9498F458E0A122A6A" = Base.encode16(sym_key_iv) + end + end + + describe ".decrypt/2" do + @passphrase "passphrase" + test "decrypts Secret-Key Packet given a valid passphrase" do + assert [ + %Packet{tag: %PacketTag{tag: {5, "Secret-Key Packet"}}} = sk_packet + | _ + ] = OpenPGP.list_packets(@rsa2048_priv) + + assert {%SecretKeyPacket{} = sk_packet_decoded, <<>>} = + sk_packet + |> Util.concat_body() + |> SecretKeyPacket.decode() + + assert %SecretKeyPacket{secret_key_material: material} = SecretKeyPacket.decrypt(sk_packet_decoded, @passphrase) + + assert {secret_exp_d, prime_val_p, prime_val_q, secret_u} = material + + assert "0206152887DF8678CAC235EC5FD1ED537B2275D01242C306451EF7BFB005E2242F021BF46" <> + "EA996A74683766DC792C2D01A8253098CDE7C9A2F64017C8814DE4A69E276D93581AC77" <> + "CB81C672D442FEA242A03DC7C609FAC0F46B3C0755DE97D408BC7F41D4BE01D7252AD63" <> + "7F901C3AF34FA13E44E12CDF5C63C46CED14A4C73B8A88D9B6278A995DB0F49778169E2" <> + "AD4A774D7C33657617D7469594A02E5A54336766A804339AD4B5B27AC16660BA4584D4B" <> + "7F7E1FE4246C2D1B204AF90E53D1EA60A20AF9CBEF476867B61F979D523773F15147D7E" <> + "4E8A520F4FAA4FFFFC6CC5EAE8581DFF75BB05988A9B12D361893E4F754E964F3B6CA1E" <> + "237AE0D479807" = Base.encode16(secret_exp_d) + + assert "E18852EA986485496F76F75A2EDA0891AC6E6D5A7859B59F5B1042D07C61B2CC526041D4C" <> + "4E653EB78213504EE62412B98F45E254FC346FAE03E45F624EEFAD58B3034F678B845B0" <> + "C84E9DEEA3D6E32B0D724620FC4DA3507107559432A61245EAA90B23C9730005FAD35D1" <> + "CAE634F6E02BA74F630FFC4344A4CC59A72C6E8A3" = Base.encode16(prime_val_p) + + assert "FBA4A4B85E505D761A99078E4EA26B3673D8E6750447C90E324812155DE53EBDD9F6DF129" <> + "E53DA03DCD2FDE97EA6FA3E34F0EF2338EB50383361E4B832D51BFF0BF28DBD71F95144" <> + "F823D3711E1D8C6983D04FACD842AA7D706E69A2BB4827FAEE2319CB72C5DAA248FAFD0" <> + "AEE4A05B39237ABAAE0C02AE2B66E7EE3988F58B3" = Base.encode16(prime_val_q) + + assert "49548DD97FC278ABDC1D67EEF641B84AD5225C34C19EB72C70FE548FD9E7CAAEF7B43013A" <> + "BBF97C5FBE281A5C602CA7055641C7D3169B459CBB9DDE37164B17A3E7EAE7BAC3986C5" <> + "239ED8B4E963E3C69DDDF8608DF11FBE1D2E97AE26D62B7882C045708E2BE8B684AF5F6" <> + "EAF22450D96B244CA2873569C407AE3F9D2252428" = Base.encode16(secret_u) + end + + test "raises error if passphrase invalid" do + assert [ + %Packet{tag: %PacketTag{tag: {5, "Secret-Key Packet"}}} = sk_packet + | _ + ] = OpenPGP.list_packets(@rsa2048_priv) + + assert {%SecretKeyPacket{} = sk_packet_decoded, <<>>} = + sk_packet + |> Util.concat_body() + |> SecretKeyPacket.decode() + + expected_error = + "Expected SecretKeyPacket checksum to be \"D243B4448D3EC2116BC163ED0CC4A5BD42C853EE\", got \"CF1B516FCC942C8796C20EAF2F045056835BF0CE\". Maybe incorrect passphrase?" + + assert_raise RuntimeError, expected_error, fn -> + SecretKeyPacket.decrypt(sk_packet_decoded, "invalid") + end + end + end +end diff --git a/test/open_pgp/util_test.exs b/test/open_pgp/util_test.exs new file mode 100644 index 0000000..545571a --- /dev/null +++ b/test/open_pgp/util_test.exs @@ -0,0 +1,4 @@ +defmodule OpenPGP.UtilTest do + use OpenPGP.Test.Case, async: true + doctest OpenPGP.Util +end diff --git a/test/open_pgp_test.exs b/test/open_pgp_test.exs new file mode 100644 index 0000000..c2d9ed7 --- /dev/null +++ b/test/open_pgp_test.exs @@ -0,0 +1,256 @@ +defmodule OpenPGPTest do + use OpenPGP.Test.Case, async: true + doctest OpenPGP + + alias OpenPGP.CompressedDataPacket + alias OpenPGP.IntegrityProtectedDataPacket + alias OpenPGP.LiteralDataPacket + alias OpenPGP.Packet + alias OpenPGP.Packet.BodyChunk + alias OpenPGP.Packet.PacketTag + alias OpenPGP.PublicKeyEncryptedSessionKeyPacket + alias OpenPGP.PublicKeyPacket + alias OpenPGP.SecretKeyPacket + + @moduledoc """ + ## Handy GPG commands + + Generate keyring with RSA2048 algo: + `gpg --batch --passphrase "passphrase" --quick-generate-key "John Doe (RSA2048) " rsa2048 default never` + + Export private key: + `gpg --export-secret-keys "john.doe@example.com" > test/fixtures/rsa2048-priv.pgp` + + Inspect packets: + `gpg --verbose --list-packets test/fixtures/rsa2048-priv.pgp` + + Add subkey with Encryption capability: + ``` + gpg --edit-key + gpg> addkey + Please select what kind of key you want: + (3) DSA (sign only) + (4) RSA (sign only) + (5) Elgamal (encrypt only) + (6) RSA (encrypt only) + (14) Existing key from card + Your selection? 6 + ... + """ + + @rsa2048_priv File.read!("test/fixtures/rsa2048-priv.pgp") + @encrypted_file File.read!("test/fixtures/words.dict.gpg") + + describe ".list_packets/1" do + test "decode all packets in a message with secret key packets (does not cast packets)" do + assert [ + %Packet{ + body: [ + %BodyChunk{ + chunk_length: {:fixed, 966}, + data: <<4, _::binary>>, + header_length: 2 + } + ], + tag: %PacketTag{ + format: :old, + length_type: {1, "two-octet"}, + tag: {5, "Secret-Key Packet"} + } + }, + %Packet{ + body: [ + %BodyChunk{ + chunk_length: {:fixed, 41}, + data: "John Doe (RSA2048) ", + header_length: 1 + } + ], + tag: %PacketTag{ + format: :old, + length_type: {0, "one-octet"}, + tag: {13, "User ID Packet"} + } + }, + %Packet{ + body: [ + %BodyChunk{ + chunk_length: {:fixed, 334}, + data: <<4, _::binary>>, + header_length: 2 + } + ], + tag: %PacketTag{ + format: :old, + length_type: {1, "two-octet"}, + tag: {2, "Signature Packet"} + } + }, + %Packet{ + body: [ + %BodyChunk{ + chunk_length: {:fixed, 966}, + data: <<4, _::binary>>, + header_length: 2 + } + ], + tag: %PacketTag{ + format: :old, + length_type: {1, "two-octet"}, + tag: {7, "Secret-Subkey Packet"} + } + }, + %Packet{ + body: [ + %BodyChunk{ + chunk_length: {:fixed, 310}, + data: <<4, _::binary>>, + header_length: 2 + } + ], + tag: %PacketTag{ + format: :old, + length_type: {1, "two-octet"}, + tag: {2, "Signature Packet"} + } + } + ] = OpenPGP.list_packets(@rsa2048_priv) + end + + test "decode all packets in a message with encrypted data packets (does not cast packets)" do + assert [ + %Packet{ + body: [ + %BodyChunk{chunk_length: {:fixed, 268}} + ], + tag: %PacketTag{ + format: :old, + length_type: {1, "two-octet"}, + tag: {1, "Public-Key Encrypted Session Key Packet"} + } + }, + %Packet{ + body: [ + %BodyChunk{chunk_length: {:partial, 8192}}, + %BodyChunk{chunk_length: {:partial, 8192}}, + %BodyChunk{chunk_length: {:partial, 8192}}, + %BodyChunk{chunk_length: {:partial, 4096}}, + %BodyChunk{chunk_length: {:partial, 2048}}, + %BodyChunk{chunk_length: {:partial, 1024}}, + %BodyChunk{chunk_length: {:partial, 512}}, + %BodyChunk{chunk_length: {:fixed, 332}} + ], + tag: %PacketTag{ + format: :new, + length_type: nil, + tag: {18, "Sym. Encrypted and Integrity Protected Data Packet"} + } + } + ] = OpenPGP.list_packets(@encrypted_file) + end + end + + test "decode secret key message" do + assert [ + %SecretKeyPacket{}, + %Packet{tag: %PacketTag{tag: {13, "User ID Packet"}}}, + %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}}, + %SecretKeyPacket{}, + %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}} + ] = @rsa2048_priv |> OpenPGP.list_packets() |> OpenPGP.cast_packets() + end + + test "decode Sym. Encrypted and Integrity Protected message" do + assert [ + %PublicKeyEncryptedSessionKeyPacket{}, + %IntegrityProtectedDataPacket{} + ] = @encrypted_file |> OpenPGP.list_packets() |> OpenPGP.cast_packets() + end + + @passphrase "passphrase" + test "full integration: load private key and decrypt encrypted file" do + ################################### + ### Load encrypted message/file ### + ################################### + + assert [ + %PublicKeyEncryptedSessionKeyPacket{} = pkesk_packet, + %IntegrityProtectedDataPacket{} = ipdata_packet + ] = @encrypted_file |> OpenPGP.list_packets() |> OpenPGP.cast_packets() + + assert %PublicKeyEncryptedSessionKeyPacket{public_key_id: public_key_id} = pkesk_packet + + ####################### + ### Load secret key ### + ####################### + + assert keyring = + [ + %SecretKeyPacket{}, + %Packet{tag: %PacketTag{tag: {13, "User ID Packet"}}}, + %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}}, + %SecretKeyPacket{}, + %Packet{tag: %PacketTag{tag: {2, "Signature Packet"}}} + ] = @rsa2048_priv |> OpenPGP.list_packets() |> OpenPGP.cast_packets() + + sk_packet = + Enum.find_value(keyring, fn + %SecretKeyPacket{public_key: %PublicKeyPacket{id: ^public_key_id}} = packet -> packet + _ -> nil + end) + + sk_packet_decrypted = SecretKeyPacket.decrypt(sk_packet, @passphrase) + + ################################ + ### Decode encrypted message ### + ################################ + + pkesk_packet_decrypted = + PublicKeyEncryptedSessionKeyPacket.decrypt( + pkesk_packet, + sk_packet_decrypted + ) + + ipdata_packet_decrypted = IntegrityProtectedDataPacket.decrypt(ipdata_packet, pkesk_packet_decrypted) + + assert %IntegrityProtectedDataPacket{ + version: 1, + ciphertext: "" <> _, + plaintext: plaintext + } = ipdata_packet_decrypted + + assert [ + %CompressedDataPacket{ + algo: {2, "ZLIB [RFC1950]"}, + data_deflated: <<_::bitstring>>, + data_inflated: data_inflated + } + ] = plaintext |> OpenPGP.list_packets() |> OpenPGP.cast_packets() + + assert [ + %LiteralDataPacket{ + format: {<<0x62>>, :binary}, + file_name: "words.dict", + created_at: ~U[2024-01-04 00:27:32Z], + data: data + } + ] = data_inflated |> OpenPGP.list_packets() |> OpenPGP.cast_packets() + + assert 104_475 == byte_size(data) + + assert """ + A + a + aa + aal + aalii + aam + Aani + aardvark + aardwolf + Aaron + """ <> _ = data + + assert "B47C587A45BBC76310CED7FA05E7BB3DC1F3FB07" == Base.encode16(:crypto.hash(:sha, data)) + end +end diff --git a/test/support/case.ex b/test/support/case.ex new file mode 100644 index 0000000..73eeed4 --- /dev/null +++ b/test/support/case.ex @@ -0,0 +1,11 @@ +defmodule OpenPGP.Test.Case do + @moduledoc false + + use ExUnit.CaseTemplate + + using do + quote do + import OpenPGP.Test.Case + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()