Repository navigation
Description
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' ← validExpected 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
mainsource as of 2026-06-07. - Python 3.12,
cryptography46.x,jwcrypto1.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
fix(jwe): when decrypt, use the original aad base64 value
lepture commentedon Jun 8, 2026
Thanks for the report, it should be fixed with d6a3d15
fix(jwe): when decrypt, use the original aad base64 value
alexminza commentedon Jun 8, 2026
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.9while there was an older higher version release1.7.0https://github.com/authlib/joserfc/releaseslepture commentedon Jun 8, 2026
@alexminza I've released it in 1.6.9 and 1.7.1