Sign JWS and encrypt JWE envelopes

Copy Markdown View Source

What you will build: A compact signed JWS wrapped in an encrypted JWE, with authentication of both envelopes before reading the payload. Prerequisite: RequestSeal installed; no framework is needed. Sources: RFC 7515, RFC 7516, and RFC 7518.

1. Sign and verify JWS

{_public_bytes, seed} = :crypto.generate_key(:eddsa, :ed25519)
{:ok, jose_handle} = RequestSeal.Custody.Local.new({:jws, "EdDSA"}, {:ed25519, seed})
{:ok, jose_key} = RequestSeal.Custody.public_key(jose_handle)
{:ok, token} = RequestSeal.JOSE.JWS.sign([{"alg", "EdDSA"}], "signed payload", jose_handle)

Trust the public key and allow only EdDSA when verifying:

jws_policy = %{
  algorithms: ["EdDSA"],
  timeout: 5_000,
  key_resolver: fn %{algorithm: "EdDSA"} ->
    {:ok, %{algorithm: "EdDSA", key: jose_key}}
  end
}

{:ok, result} = RequestSeal.JOSE.JWS.verify(token, jws_policy)
result.payload
# => "signed payload"

HTTP and JOSE algorithm tokens are separate; {:jws, "EdDSA"} binds this handle to JOSE. JWS.sign_protected/4 accepts your serialized protected JSON when exact protected bytes matter. The resolver selects trusted key material; kid and x5c alone confer no trust.

2. Encrypt the signed envelope and verify the inner signature

wrapping_key = {:aes, :crypto.strong_rand_bytes(32)}

wrap = fn algorithm, cek ->
  RequestSeal.JOSE.KeyManagement.wrap(algorithm, cek, %{}, wrapping_key)
end

{:ok, encrypted} =
  RequestSeal.JOSE.JWE.encrypt(
    [{"alg", "A256GCMKW"}, {"enc", "A256GCM"}],
    token,
    wrap
  )

Select the trusted wrapping key and verify the inner signature after decryption:

jwe_policy = %{
  algorithms: ["A256GCMKW"],
  encryption: ["A256GCM"],
  max_plaintext: 1_048_576,
  timeout: 5_000,
  key_resolver: fn %{algorithm: "A256GCMKW"} ->
    {:ok,
     %{
       algorithm: "A256GCMKW",
       unwrap: fn bytes, header ->
         RequestSeal.JOSE.KeyManagement.unwrap("A256GCMKW", bytes, header, wrapping_key)
       end
     }}
  end
}

{:ok, decrypted} = RequestSeal.JOSE.JWE.decrypt(encrypted, jwe_policy)
{:ok, inner} = RequestSeal.JOSE.JWS.verify(decrypted.plaintext, jws_policy)
inner.payload
# => "signed payload"

The wrapping key stays in your callback. Encryption uses fresh content-encryption keys and IVs from the CSPRNG. RequestSeal.JOSE.Nested.verify/4 also supports one-level JWS-in-JWE verification with explicit outer/inner policies and content_types: ["JWS"] when the outer protected header declares cty: "JWS". The envelope above omits cty; add {"cty", "JWS"} to the JWE header before using Nested.verify/4, or it rejects with :content_type_mismatch. The inner JWS may itself carry a non-JOSE cty such as application/json; only values that name another JOSE envelope (application/jose, application/jose+json, application/jws, application/jwe, or RFC 7519's application/jwt, with RFC 7515 Section 4.1.10's abbreviation rule) reject as :nesting_depth, because Nested verifies exactly one level. Decryption withholds all plaintext until AEAD authentication succeeds. Recipient integrity alone does not establish sender origin, identity, or request association.

Decrypt with a recipient key handle

For RSA-OAEP, keep the recipient's private key in an algorithm-bound custody handle and return that handle from the key resolver. Decryption keys shows the import, the startup health check, and why a released handle fails exactly like a forged message.

Options and bounds

JWS algorithms are RS256, PS256, PS384, PS512, HS256, ES256, ES384, and EdDSA. JWE key management is RSA-OAEP-256, RSA-OAEP, A128GCMKW, A256GCMKW, or dir; content encryption is A128GCM or A256GCM.

Choose exact algorithm allowlists, timeout, content-encryption algorithms, and maximum plaintext. Compact envelopes are at most 1 MiB, protected JSON at most 16 KiB and depth four. Base64url is canonical and unpadded. Duplicate JSON members and floats reject. Compression, detached JWS, JSON serializations, the jku, x5u, and jwk key headers, b64, and crit are unsupported (x5c is accepted only as untrusted data); choose a different protocol contract when those are required.

This is generic JOSE, with no JWT claims policy, implicit key lookup, provider profile, trust anchor, freshness, replay, or authorization. Apply those application rules separately. Tests use Node WebCrypto as an independent verifier, in addition to published vectors and local cryptography.

Errors

RequestSeal.JOSE.Error carries bounded reasons and no input text. :invalid_signature rejects a JWS mismatch. :decryption_failed covers key unwrap and AEAD failure without releasing partial plaintext. :detached_payload rejects empty JWS payloads. :algorithm_not_permitted, :invalid_options, and :limit require a supported explicit contract or smaller input. :unknown_key and :key_resolver_failed come from your resolver; Nested adds :content_type_mismatch and :nesting_depth; a failing wrap callback is :signer_failed. Only :deadline_exceeded is retryable. Each function's documentation lists the exact reasons it can return.

Module docs: RequestSeal.JOSE, RequestSeal.JOSE.JWS, RequestSeal.JOSE.JWE, RequestSeal.JOSE.Nested, RequestSeal.JOSE.KeyManagement, RequestSeal.JOSE.Error.