defmodule Pdf.Reader.Encryption.StandardHandler do @moduledoc """ Parses the PDF `/Encrypt` dictionary into a `%StandardHandler{}` struct. Supports Standard Security Handler revisions R=2 (V=1), R=3 (V=2), R=4 (V=4, Crypt Filters + AES-128), and R=6 (V=5, AES-256, PDF 2.0). Any other `/Filter` value returns `{:error, :encrypted_unsupported_handler}`. This module is a pure data-extraction layer. It does NOT: - Validate `/O` or `/U` password hashes. - Derive the file encryption key. - Perform any cryptographic operations. Those operations are handled by `Pdf.Reader.Encryption.V1V2`, `Pdf.Reader.Encryption.V4`, and `Pdf.Reader.Encryption.V5`. ## SecurityHandler struct fields | Field | Source | Default | |--------------------|-------------------------------|--------------| | `:version` | `/V` | `nil` | | `:revision` | `/R` | `nil` | | `:length` | `/Length` | `nil` | | `:o` | `/O` (raw bytes, unwrapped) | `nil` | | `:u` | `/U` (raw bytes, unwrapped) | `nil` | | `:oe` | `/OE` (V5 only) | `nil` | | `:ue` | `/UE` (V5 only) | `nil` | | `:perms` | `/Perms` (V5 only, 16 bytes) | `nil` | | `:p` | `/P` (32-bit signed integer) | `nil` | | `:cf` | `/CF` sub-dict (V4/V5) | `%{}` | | `:stm_filter` | `/StmF` name (V4/V5) | `nil` | | `:str_filter` | `/StrF` name (V4/V5) | `nil` | | `:encrypt_metadata`| `/EncryptMetadata` (V4+) | `true` | | `:filter` | `/Filter` name string | `nil` | | `:file_key` | populated after authentication | `nil` | | `:id` | `/ID[0]` from trailer | `nil` | ## Spec references - PDF 1.7 (ISO 32000-1) § 7.6.3.1 — Standard Security Handler: https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf - PDF 1.7 § 7.6.3.3 — Encryption Key Algorithm (R=2/3/4): https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf - PDF 2.0 (ISO 32000-2) § 7.6.4 — Standard Security Handler (R=6, V=5): https://www.pdfa.org/wp-content/uploads/2023/04/ISO_32000_2_2020_PDF_2.0_FDIS.pdf - Mozilla pdf.js src/core/crypto.js (Apache-2.0 reference implementation): https://github.com/mozilla/pdf.js/blob/master/src/core/crypto.js """ # --------------------------------------------------------------------------- # Struct # --------------------------------------------------------------------------- @type t :: %__MODULE__{ version: 1 | 2 | 4 | 5 | nil, revision: 2 | 3 | 4 | 6 | nil, length: non_neg_integer() | nil, o: binary() | nil, u: binary() | nil, oe: binary() | nil, ue: binary() | nil, perms: binary() | nil, p: integer() | nil, cf: %{String.t() => map()}, stm_filter: String.t() | nil, str_filter: String.t() | nil, encrypt_metadata: boolean(), filter: String.t() | nil, file_key: binary() | nil, id: binary() | nil } defstruct version: nil, revision: nil, length: nil, o: nil, u: nil, oe: nil, ue: nil, perms: nil, p: nil, cf: %{}, stm_filter: nil, str_filter: nil, encrypt_metadata: true, filter: nil, file_key: nil, id: nil # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @doc """ Parses an Encrypt dict map (as returned by `Pdf.Reader.Parser`) into a `%StandardHandler{}` struct. ## Parameters - `encrypt_dict` — a plain `%{}` map where values follow Parser tagging conventions: integers as integers, names as `{:name, string}`, byte strings as `{:string, binary}` or `{:hex_string, binary}`, booleans as booleans, sub-dicts as plain maps. - `doc_id` — the raw binary of `/ID[0]` from the document trailer. Stored on the struct as `:id` for use by key-derivation modules. ## Returns - `{:ok, %StandardHandler{}}` — for `/Filter /Standard` dicts. - `{:error, :encrypted_unsupported_handler}` — for any other `/Filter` value, or when `/Filter` is absent. Note: the `:file_key` field is always `nil` on return. Authentication and key derivation are handled by V1V2/V4/V5 modules. """ @spec parse(map(), binary()) :: {:ok, t()} | {:error, :encrypted_unsupported_handler} def parse(encrypt_dict, doc_id) when is_map(encrypt_dict) and is_binary(doc_id) do with :ok <- check_filter(encrypt_dict) do sh = %__MODULE__{ version: get_integer(encrypt_dict, "V"), revision: get_integer(encrypt_dict, "R"), length: get_integer(encrypt_dict, "Length"), o: unwrap_string(Map.get(encrypt_dict, "O")), u: unwrap_string(Map.get(encrypt_dict, "U")), oe: unwrap_string(Map.get(encrypt_dict, "OE")), ue: unwrap_string(Map.get(encrypt_dict, "UE")), perms: unwrap_string(Map.get(encrypt_dict, "Perms")), p: get_integer(encrypt_dict, "P"), cf: get_cf(encrypt_dict), stm_filter: get_name(encrypt_dict, "StmF"), str_filter: get_name(encrypt_dict, "StrF"), encrypt_metadata: get_encrypt_metadata(encrypt_dict), filter: get_name(encrypt_dict, "Filter"), id: doc_id } {:ok, sh} end end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- # Validate /Filter — must be the name "Standard" defp check_filter(dict) do case Map.get(dict, "Filter") do {:name, "Standard"} -> :ok _ -> {:error, :encrypted_unsupported_handler} end end # Extract a bare integer from the dict; return nil if absent or wrong type defp get_integer(dict, key) do case Map.get(dict, key) do v when is_integer(v) -> v _ -> nil end end # Extract a name string from a {:name, string} entry; nil otherwise defp get_name(dict, key) do case Map.get(dict, key) do {:name, name} when is_binary(name) -> name _ -> nil end end # Unwrap {:string, bin} or {:hex_string, bin} → raw binary; nil otherwise defp unwrap_string({:string, bin}) when is_binary(bin), do: bin defp unwrap_string({:hex_string, bin}) when is_binary(bin), do: bin defp unwrap_string(_), do: nil # Extract /CF sub-dict; return %{} when absent or not a map defp get_cf(dict) do case Map.get(dict, "CF") do m when is_map(m) -> m _ -> %{} end end # /EncryptMetadata defaults to true; explicit false → false; anything else → true defp get_encrypt_metadata(dict) do case Map.get(dict, "EncryptMetadata") do false -> false _ -> true end end end