RequestSeal.JOSE.JWE (RequestSeal v0.2.0)

Copy Markdown View Source

Compact RFC 7516 encryption with explicitly pinned algorithms and AES GCM.

encrypt/4 takes ordered protected header pairs with alg/enc, plaintext bytes, and a PublicKey or arity-two wrapper (wire_algorithm, generated_cek). The wrapper returns {:ok, %{encrypted_key: bytes, header: additions_map}}; additions are protected, cannot replace caller members, and may contain only iv/tag for GCMKW. Protected headers use compact JSON: top-level members keep caller order, followed by additions sorted by member name; nested JSON objects are serialized in map order. For JWS signatures needing exact protected bytes, use RequestSeal.JOSE.JWS.sign_protected/4. For dir, the wrapper must provision the generated CEK to its recipient through caller-owned custody and return an empty encrypted key. No symmetric secret can be supplied in a public key or header. Only timeout: milliseconds is accepted (default 5,000; 1–300,000).

decrypt/2 requires exactly :algorithms, :encryption, :max_plaintext (1–1,048,576), :timeout (1–300,000), and :key_resolver. Algorithms are unique nonempty lists selected from RSA-OAEP-256, RSA-OAEP, A128GCMKW, A256GCMKW, dir; content encryption is A128GCM or A256GCM. The arity-one resolver receives %{algorithm: name, encryption: name, header: map} and returns {:ok, %{algorithm: same_name, unwrap: arity_two_fun}}, {:ok, %{algorithm: same_name, key: custody_handle}}, or :error. A custody handle must bind {:jwe, same_name} with :unwrap capability; RSA-OAEP private operations stay inside its sensitive local holder. The existing callback path can still use caller-owned raw key material. Unwrap receives (encrypted_key_bytes, header) and returns {:ok, cek} or {:error, term}. Resolver and callback unwrap execute in JWE's sensitive worker. On the handle path, the CEK passes from the sensitive local holder through a separate sensitive custody runner and middle process to that JWE worker. These four processes see the CEK; JWE's outer middle process and the decrypt caller receive only the authenticated result. All work shares one absolute deadline. Unwrap failure uses a fresh random CEK and still attempts tag authentication; both paths return :decryption_failed.

A released or unavailable resolved handle makes decryption fail with the same complete error as a forged message, by design. Check RequestSeal.Custody.public_key(handle) at startup: {:ok, public_key} confirms public resolution; {:error, error} reports custody availability separately from message processing. Handle algorithm/capability mismatch also follows the unwrap-failure path. Resolver algorithm mismatch remains a key-selection error, and deadline expiration remains distinct.

CEK/IV use the real CSPRNG. Decrypted data stays in the private worker until full authentication succeeds; no partial result is returned. IVs are 12 bytes, tags 16 bytes, CEKs exactly 16/32 bytes. The protected segment is verbatim AAD. Returns {:ok, compact} / {:ok, Result} or {:error, JOSE.Error}.

Summary

Functions

Authenticate and decrypt a compact JWE under an explicit policy (RFC 7516).

Encrypt plaintext into compact JWE with fresh CEK and IV bytes (RFC 7516).

Functions

decrypt(compact, policy)

@spec decrypt(binary(), map()) ::
  {:ok, RequestSeal.JOSE.JWE.Result.t()} | {:error, RequestSeal.JOSE.Error.t()}

Authenticate and decrypt a compact JWE under an explicit policy (RFC 7516).

The policy must be a map containing exactly these atom keys:

  • :algorithms: a nonempty unique list selected from "RSA-OAEP-256", "RSA-OAEP", "A128GCMKW", "A256GCMKW", and "dir".
  • :encryption: a nonempty unique list selected from "A128GCM" and "A256GCM".
  • :max_plaintext: an integer from 1 through 1,048,576 bytes.
  • :timeout: an integer from 1 through 300,000 milliseconds.
  • :key_resolver: an arity-one function receiving %{algorithm: wire_name, encryption: wire_name, header: decoded_header_map}. It returns :error, {:ok, %{algorithm: same_name, unwrap: arity_two_function}}, or {:ok, %{algorithm: same_name, key: custody_handle}}, with exactly two entry keys. Unwrap receives (encrypted_key_bytes, header) and returns {:ok, cek_bytes} or {:error, term}. The key: handle form works only for "RSA-OAEP" and "RSA-OAEP-256", binding {:jwe, same_name} with only :unwrap capability; RSA private operations stay in custody. "dir", "A128GCMKW", and "A256GCMKW" require the unwrap: callback. A handle supplied for one of those algorithms returns :decryption_failed.

Resolution, unwrapping, and authentication share one absolute deadline with sensitive workers and caller cancellation. No plaintext leaves the worker before full tag authentication. Returns {:ok, RequestSeal.JOSE.JWE.Result.t()} or {:error, RequestSeal.JOSE.Error.t()}. Invalid policies return :invalid_policy; unwrap and authentication failures share :decryption_failed; expiry remains :deadline_exceeded. Compact input is bounded to 1,048,576 bytes. The received protected segment is used verbatim as AAD. Recipient integrity establishes no sender identity, request association, claims validation, or authorization.

This is a local RFC 7518 round trip, using real key wrapping and AEAD:

iex> wrapping_key = {:aes, :crypto.strong_rand_bytes(32)}
iex> wrap = fn algorithm, cek -> RequestSeal.JOSE.KeyManagement.wrap(algorithm, cek, %{}, wrapping_key) end
iex> {:ok, token} = RequestSeal.JOSE.JWE.encrypt([{"alg", "A256GCMKW"}, {"enc", "A256GCM"}], "payload", wrap)
iex> unwrap = fn bytes, header -> RequestSeal.JOSE.KeyManagement.unwrap("A256GCMKW", bytes, header, wrapping_key) end
iex> policy = %{algorithms: ["A256GCMKW"], encryption: ["A256GCM"], max_plaintext: 1_024, timeout: 5_000, key_resolver: fn _ -> {:ok, %{algorithm: "A256GCMKW", unwrap: unwrap}} end}
iex> {:ok, result} = RequestSeal.JOSE.JWE.decrypt(token, policy)
iex> result.plaintext
"payload"
iex> {:error, error} = RequestSeal.JOSE.JWE.decrypt(token, Map.delete(policy, :encryption))
iex> error.reason
:invalid_policy

encrypt(header, plaintext, recipient, opts \\ [])

@spec encrypt(
  RequestSeal.JOSE.header(),
  binary(),
  RequestSeal.PublicKey.t() | function(),
  keyword()
) ::
  {:ok, binary()} | {:error, RequestSeal.JOSE.Error.t()}

Encrypt plaintext into compact JWE with fresh CEK and IV bytes (RFC 7516).

header is an ordered list of {string_name, json_value} pairs with "alg" selected from "RSA-OAEP-256", "RSA-OAEP", "A128GCMKW", "A256GCMKW", or "dir", and "enc" selected from "A128GCM" or "A256GCM". Members must be unique. "crit" rejects as :unsupported_critical_header, "zip" as :compression_unsupported, and "jku", "x5u", "jwk", and "b64" as :unsupported_header, regardless of their values. Plaintext and the resulting compact envelope are bounded to 1,048,576 bytes.

recipient is a compatible RSA RequestSeal.PublicKey or an arity-two function receiving (wire_algorithm, generated_cek_bytes). The function returns {:ok, %{encrypted_key: bytes, header: additions_map}} with exactly those keys. Additions may contain only "iv"/"tag" for GCM key wrapping, cannot replace caller members, and become authenticated protected headers. For "dir", the function provisions the generated CEK through caller-owned custody and returns an empty encrypted key. Symmetric secrets cannot be supplied as public keys.

opts accepts only timeout: milliseconds (default 5,000; integer 1–300,000). This function accepts no policy map; decryption selects its own explicit policy. Wrapping and encryption run in a sensitive worker with deadline and caller cancellation. Returns {:ok, compact_binary} or {:error, RequestSeal.JOSE.Error.t()}. Error reasons are :invalid_options, :invalid_serialization, :limit, :invalid_header, :duplicate_member, :unsupported_critical_header, :compression_unsupported, :unsupported_header, :algorithm_not_permitted, :algorithm_mismatch, :invalid_base64, :invalid_iv, :invalid_tag, :decryption_failed, :entropy_failure, :signer_failed, and :deadline_exceeded. Callback failure or an invalid wrap result returns :signer_failed; malformed encrypted-key bytes return :decryption_failed. This function has no key resolver and does not return :key_resolver_failed. Only :deadline_exceeded is retryable. See the module documentation for protected JSON serialization order.

This example uses actual RFC 7518 AES GCM key wrapping and content encryption:

iex> wrapping_key = {:aes, :crypto.strong_rand_bytes(32)}
iex> wrap = fn algorithm, cek -> RequestSeal.JOSE.KeyManagement.wrap(algorithm, cek, %{}, wrapping_key) end
iex> {:ok, token} = RequestSeal.JOSE.JWE.encrypt([{"alg", "A256GCMKW"}, {"enc", "A256GCM"}], "payload", wrap, timeout: 5_000)
iex> is_binary(token) and length(String.split(token, ".")) == 5
true
iex> {:error, error} = RequestSeal.JOSE.JWE.encrypt([{"alg", "A256GCMKW"}, {"enc", "A256GCM"}], "payload", wrap, timeout: 0)
iex> error.reason
:invalid_options