Skip to content

JWE compact decrypt recomputes the GCM AAD from a re-serialized protected header instead of the received octets (RFC 7516 §5.1) — rejects valid cross-producer tokens #101

Closed

Description

@alexminza

Summary

When decrypting a JWE Compact Serialization, joserfc computes the AEAD Additional Authenticated Data (AAD) by re-serializing the parsed protected header rather than using the header octets as received in the token. Per RFC 7516 §5.1 (and RFC 7515 §4, which warns that JSON is not canonical), the AAD for compact serialization is ASCII(BASE64URL(UTF8(JWE Protected Header))) over the value received. Because joserfc re-encodes a normalized form (json.dumps(separators=(",",":"))), it rejects otherwise-valid tokens produced by any implementation whose protected-header serialization is not byte-identical to joserfc's compact form — e.g. jwcrypto, which emits spaced JSON ({"alg": "dir", ...}).

The failure surfaces as joserfc.errors.DecodeError (wrapping cryptography's InvalidTag).

Affected code (upstream main, also 1.7.0)

src/joserfc/_rfc7516/message.py, _perform_decrypt:

aad = json_b64encode(obj.protected)            # re-serializes the parsed dict
...
msg = enc.decrypt(ciphertext, tag, cek, iv, aad)

src/joserfc/util.py:

def json_b64encode(data: dict[str, Any]) -> bytes:
    text = json.dumps(data, ensure_ascii=True, separators=(",", ":"))   # normalized, not the received bytes
    return urlsafe_b64encode(to_bytes(text, "ascii"))

The same json_b64encode(obj.protected) is used on the encrypt path, so joserfc is self-consistent (its own tokens round-trip) — but a token from another RFC-conformant producer is rejected.

Reproduction

import base64, json
from joserfc import jwe
from joserfc.jwk import OctKey
from joserfc.jwe import JWERegistry
from jwcrypto import jwk as J, jwe as JW

raw = b"\x01" * 32
reg = JWERegistry(algorithms=["dir", "A256GCM"])
k  = OctKey.import_key(raw)
jk = J.JWK(kty="oct", k=base64.urlsafe_b64encode(raw).decode().rstrip("="))

# jwcrypto produces a valid dir/A256GCM compact token (spaced protected header):
e = JW.JWE(plaintext=b"hello",
           protected=json.dumps({"alg": "dir", "enc": "A256GCM", "kid": "k-1"}))
e.add_recipient(jk)
token = e.serialize(compact=True)

# jwcrypto reads it back fine (authenticates the received octets):
chk = JW.JWE(); chk.deserialize(token, key=jk); assert chk.payload == b"hello"

# joserfc rejects it — DecodeError / InvalidTag:
jwe.decrypt_compact(token, k, registry=reg)

Proof the token is valid (it is joserfc's AAD derivation that is wrong)

A manual AES-GCM decrypt using the token's verbatim stored protected-header base64 as AAD succeeds:

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
p = token.split(".")
def b64(s): s += "=" * (-len(s) % 4); return base64.urlsafe_b64decode(s)
iv, ct, tag = b64(p[2]), b64(p[3]), b64(p[4])
aad = p[0].encode("ascii")                       # ASCII(BASE64URL(protected)) — the received octets
print(AESGCM(raw).decrypt(iv, ct + tag, aad))    # b'hello'  ← valid

Expected behavior

On decrypt, use the received protected-header segment (the first compact part, verbatim) as the AAD — not a re-serialization of the parsed dict. This is what RFC 7516 §5.1 step 14 specifies and what other implementations (e.g. jwcrypto, JOSE libraries in other languages) do, and it is required for cross-implementation interoperability.

Impact / severity

  • Interoperability / availability, not security: joserfc is stricter than the spec (it rejects some valid tokens), so there is no confidentiality or integrity weakening.
  • Bites only when joserfc must read tokens produced by a different implementation; reading its own tokens is unaffected.

Environment

  • joserfc 1.7.0; reproduced against upstream main source as of 2026-06-07.
  • Python 3.12, cryptography 46.x, jwcrypto 1.5.7.

References

  • RFC 7516 §5.1 (JWE decryption), §5.2 — AAD = ASCII(BASE64URL(UTF8(JWE Protected Header))).
  • RFC 7515 §4 — JSON object members have no canonical order/whitespace; implementations must operate on the encoded form.

Activity

  1. lepture commented on Jun 8, 2026

    @lepture
    Member

    Thanks for the report, it should be fixed with d6a3d15

  2. alexminza commented on Jun 8, 2026

    @alexminza
    Author

    Thank you for the fix, @lepture!

    I see you just made a release with it, but it seems odd that you released version 1.6.9 while there was an older higher version release 1.7.0 https://github.com/authlib/joserfc/releases

  3. lepture commented on Jun 8, 2026

    @lepture
    Member

    @alexminza I've released it in 1.6.9 and 1.7.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions