From 5cac2f455cad8c60a741aab901baf7128b7f464c Mon Sep 17 00:00:00 2001 From: achamayou Date: Sun, 8 Nov 2026 16:31:24 +0000 Subject: [PATCH] Follow RFC 9597 for CWT Claims in COSE headers draft-ietf-cose-cwt-claims-in-headers-06 is now RFC 9597. IANA assigned label 15 to the CWT Claims header parameter; the draft's requested label 13 went to kcwt (RFC 9528). COSEHeaders.CWT_CLAIMS is now 15 and 13 is no longer interpreted as CWT Claims. CWT.decode() now checks the CWT Claims header parameter in both the protected and unprotected headers of every COSE layer of a nested CWT, not only the protected header of the innermost layer. Header and payload claim values must be identical CBOR data items. A parameter that is not a map, or that occurs in both the protected and unprotected header, is rejected with ValueError. Update README and CHANGES accordingly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGES.rst | 5 ++ README.md | 37 ++++++++-- cwt/cwt.py | 52 +++++++++++--- cwt/enums.py | 2 +- tests/test_cwt.py | 147 +++++++++++++++++++++++++++++++++++++++ tests/test_cwt_sample.py | 2 +- 6 files changed, 228 insertions(+), 17 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 4e70e63c..935d3619 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,11 @@ Unreleased ---------- - Correct COSE signing header annotations to accept dictionaries and ResolvedHeader in strict type checkers. +- Follow RFC 9597 (CWT Claims in COSE Headers), which supersedes draft-ietf-cose-cwt-claims-in-headers-06. + - **Breaking change**: ``COSEHeaders.CWT_CLAIMS`` is now ``15``, the label assigned by IANA in RFC 9597 (it was ``13``, the label requested by draft-06). IANA assigned ``13`` to the unrelated ``kcwt`` header parameter (RFC 9528), so ``13`` is no longer interpreted as CWT Claims. Tokens using the draft label are no longer cross-checked by ``CWT.decode()``. + - ``CWT.decode()`` now checks the CWT Claims header parameter in unprotected headers and in every COSE layer of a nested CWT, not only in the protected header of the innermost layer. + - ``CWT.decode()`` now requires header and payload claim values to be identical CBOR data items (e.g. ``1``, ``1.0`` and ``True`` no longer compare equal), as RFC 9597 requires. + - ``CWT.decode()`` now raises ``ValueError`` if the CWT Claims header parameter is not a map or occurs in both the protected and unprotected headers. Version 3.3.0 ------------- diff --git a/README.md b/README.md index cd3d873d..a286ebc1 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ implementation compliant with: - [RFC9053: CBOR Object Signing and Encryption (COSE): Initial Algorithms](https://www.rfc-editor.org/rfc/rfc9053.html) - [RFC9338: CBOR Object Signing and Encryption (COSE): Countersignatures](https://www.rfc-editor.org/rfc/rfc9338.html) - experimental - [RFC8392: CWT (CBOR Web Token)](https://tools.ietf.org/html/rfc8392) +- [RFC9597: CBOR Web Token (CWT) Claims in COSE Headers](https://www.rfc-editor.org/rfc/rfc9597.html) - [draft-23: Use of HPKE with COSE](https://www.ietf.org/archive/id/draft-ietf-cose-hpke-23.html) - experimental -- [draft-06: CWT Claims in COSE Headers](https://www.ietf.org/archive/id/draft-ietf-cose-cwt-claims-in-headers-06.html) - experimental - [draft-13: Fully-Specified Algorithms for JOSE and COSE](https://www.ietf.org/archive/id/draft-ietf-jose-fully-specified-algorithms-13.html) - and related various specifications. See [Referenced Specifications](#referenced-specifications). @@ -1562,9 +1562,31 @@ assert readable.get("ext_4") == 123 ### CWT with CWT claims in COSE headers -Python CWT supports [CWT Claims in COSE Headers](https://www.ietf.org/archive/id/draft-ietf-cose-cwt-claims-in-headers-06.html) experimentally. - -If a CWT message has a CWT Claims header parameter in its protected header, `cwt.decode()` checks whether the values of the claims included in that parameter match the values of the corresponding claims in the payload. If they do not match, `VerifyError` is raised. +Python CWT supports [RFC9597: CBOR Web Token (CWT) Claims in COSE Headers](https://www.rfc-editor.org/rfc/rfc9597.html). +The CWT Claims header parameter uses the label `15` (`COSEHeaders.CWT_CLAIMS`), and its value is a map of CWT claims. + +**Note:** Python CWT v3.3.0 and earlier implemented [draft-06](https://www.ietf.org/archive/id/draft-ietf-cose-cwt-claims-in-headers-06.html), +which requested the label `13`. IANA assigned `15` to CWT Claims in RFC9597 and assigned `13` to the unrelated +`kcwt` header parameter ([RFC9528](https://www.rfc-editor.org/rfc/rfc9528.html)). `COSEHeaders.CWT_CLAIMS` is now `15`, +and the label `13` is no longer interpreted as CWT Claims. Tokens issued with the draft label `13` are not cross-checked +by `cwt.decode()` and should be re-issued with `15`. + +If a CWT has a CWT Claims header parameter in the protected or unprotected header of any of its COSE layers +(including the outer layers of a nested CWT), `cwt.decode()` checks that the value of each claim in that parameter is +identical to the value of the corresponding claim in the payload, if present. Values are compared as CBOR data items, +so, for example, `1`, `1.0` and `True` are not identical, while map key order does not matter. If they do not match, `VerifyError` is raised. +`ValueError` is raised if the parameter value is not a map or if the parameter occurs in both the protected and +unprotected headers. These checks are skipped if `no_verify=True` is specified. Headers of individual signers +(`COSE_Signature`) and recipients (`COSE_recipient`) are not checked. + +As recommended by RFC9597: +- Place the CWT Claims header parameter in the protected header, since unprotected header contents are malleable. +- Header parameters are not encrypted, even in `COSE_Encrypt0`/`COSE_Encrypt` messages. Do not put privacy-sensitive + claims there unless every party able to see the COSE object may see them. +- Do not act on header claims before `cwt.decode()` (or `COSE.decode*()`) has verified the message. When the payload + is detached, the application is responsible for its integrity in transport. +- To signal how the claims are to be interpreted, the `typ` header parameter + ([RFC9596](https://www.rfc-editor.org/rfc/rfc9596.html), label `16`) can be used. ```py from cwt import COSE, COSEHeaders, COSEKey, CWT, CWTClaims @@ -1585,7 +1607,7 @@ payload = cbor2.dumps( }, ) protected = { - COSEHeaders.CWT_CLAIMS: { # 13 + COSEHeaders.CWT_CLAIMS: { # 15 CWTClaims.ISS: "coap://as.example.com", } } @@ -1599,6 +1621,9 @@ assert decoded[CWTClaims.ISS] == "coap://as.example.com" assert decoded[CWTClaims.SUB] == "erikw" ``` +The CWT Claims header parameter is not restricted to CWTs. It can be set in the headers of any COSE message +created with `COSE`, including ones whose payload is not CBOR. + ### CWT with PoP Key Python CWT supports [Proof-of-Possession Key Semantics for CBOR Web Tokens (CWTs)](https://tools.ietf.org/html/rfc8747). @@ -1800,9 +1825,9 @@ Python CWT is (partially) compliant with following specifications: - [RFC8392: CWT (CBOR Web Token)](https://tools.ietf.org/html/rfc8392) - [RFC8230: Using RSA Algorithms with COSE Messages](https://tools.ietf.org/html/rfc8230) - [RFC9459: CBOR Object Signing and Encryption (COSE): AES-CTR and AES-CBC](https://www.rfc-editor.org/rfc/rfc9459.html) - experimental +- [RFC9597: CBOR Web Token (CWT) Claims in COSE Headers](https://www.rfc-editor.org/rfc/rfc9597.html) - [RFC8152: CBOR Object Signing and Encryption (COSE)](https://tools.ietf.org/html/rfc8152) - [draft-23: Use of HPKE with COSE](https://www.ietf.org/archive/id/draft-ietf-cose-hpke-23.html) - experimental -- [draft-06: CWT Claims in COSE Headers](https://www.ietf.org/archive/id/draft-ietf-cose-cwt-claims-in-headers-06.html) - experimental - [draft-13: Fully-Specified Algorithms for JOSE and COSE](https://www.ietf.org/archive/id/draft-ietf-jose-fully-specified-algorithms-13.html) - [Electronic Health Certificate Specification](https://github.com/ehn-dcc-development/hcert-spec/blob/main/hcert_spec.md) - [Technical Specifications for Digital Green Certificates Volume 1](https://ec.europa.eu/health/sites/default/files/ehealth/docs/digital-green-certificates_v1_en.pdf) diff --git a/cwt/cwt.py b/cwt/cwt.py index cf5d7e0b..4eb3063f 100644 --- a/cwt/cwt.py +++ b/cwt/cwt.py @@ -1,14 +1,16 @@ from calendar import timegm +from collections.abc import Mapping from datetime import datetime, timezone -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, Tuple, Union -from cbor2 import CBORTag +from cbor2 import CBORTag, dumps from .cbor_processor import CBORProcessor from .claims import Claims from .const import COSE_KEY_OPERATION_VALUES from .cose import COSE from .cose_key_interface import COSEKeyInterface +from .enums import COSEHeaders from .exceptions import DecodeError, VerifyError from .recipient_interface import RecipientInterface from .signer import Signer @@ -320,6 +322,12 @@ def decode( """ Verifies and decodes CWT. + Unless ``no_verify`` is set, if a CWT Claims header parameter + (`RFC 9597 `_, label 15) is + present in the protected or unprotected header of any COSE layer, the + value of each claim in it must be identical (as a CBOR data item) to the + corresponding claim in the payload, if present. + Args: data (bytes): A byte string of an encoded CWT. keys (Union[COSEKeyInterface, List[COSEKeyInterface]]): A COSE key @@ -337,12 +345,13 @@ def decode( if isinstance(cwt, CBORTag) and cwt.tag == CWT.CBOR_TAG: cwt = cwt.value keys = [keys] if isinstance(keys, COSEKeyInterface) else keys - p: Dict[Union[str, int], Any] = {} + headers: List[Tuple[Dict[Union[str, int], Any], Dict[Union[str, int], Any]]] = [] while isinstance(cwt, CBORTag): p, u, cwt = self._cose.decode_with_headers(cwt, keys) + headers.append((p, u)) cwt = self._loads(cwt) if not no_verify: - self._verify(cwt, p) + self._verify(cwt, headers) return cwt def set_private_claim_names(self, claim_names: Dict[str, int]): @@ -399,7 +408,11 @@ def _validate(self, claims: Union[Dict[int, Any], bytes]): Claims.validate(claims) return - def _verify(self, claims: Union[Dict[Union[str, int], Any], bytes], protected: Dict[Union[str, int], Any] = {}): + def _verify( + self, + claims: Union[Dict[Union[str, int], Any], bytes], + headers: Optional[List[Tuple[Dict[Union[str, int], Any], Dict[Union[str, int], Any]]]] = None, + ): if not isinstance(claims, dict): raise DecodeError("Failed to decode.") @@ -418,10 +431,31 @@ def _verify(self, claims: Union[Dict[Union[str, int], Any], bytes], protected: D else: raise ValueError("nbf should be int or float.") - if 13 in protected: # CWT claims in protected headers - for k, v in protected[13].items(): - if k in claims and claims[k] != v: - raise VerifyError(f"The CWT claim({k}) value in protected header does not match the values in the payload.") + for protected, unprotected in headers or []: + self._verify_cwt_claims_header(claims, protected, unprotected) + return + + def _verify_cwt_claims_header( + self, + claims: Dict[Union[str, int], Any], + protected: Dict[Union[str, int], Any], + unprotected: Dict[Union[str, int], Any], + ): + # RFC 9597 Section 2 + label = COSEHeaders.CWT_CLAIMS + if label in protected and label in unprotected: + raise ValueError( + f"The CWT Claims header parameter({label}) must occur only once in either the protected or unprotected header." + ) + for where, header in (("protected", protected), ("unprotected", unprotected)): + if label not in header: + continue + if not isinstance(header[label], Mapping): + raise ValueError(f"The CWT Claims header parameter({label}) should be map.") + for k, v in header[label].items(): + # Compare deterministic CBOR encodings so that e.g. 1, 1.0 and True are not identical. + if k in claims and dumps(claims[k], canonical=True) != dumps(v, canonical=True): + raise VerifyError(f"The CWT claim({k}) value in {where} header does not match the values in the payload.") return def _set_default_value(self, claims: Union[Dict[int, Any], bytes]): diff --git a/cwt/enums.py b/cwt/enums.py index f54a3567..c19251a0 100644 --- a/cwt/enums.py +++ b/cwt/enums.py @@ -25,7 +25,7 @@ class COSEHeaders(enum.IntEnum): KID_CONTEXT = 10 COUNTER_SIGNATURE_V2 = 11 COUNTER_SIGNATURE_0_V2 = 12 - CWT_CLAIMS = 13 + CWT_CLAIMS = 15 X5BAG = 32 X5CHAIN = 33 X5T = 34 diff --git a/tests/test_cwt.py b/tests/test_cwt.py index be3167bd..fbc57421 100644 --- a/tests/test_cwt.py +++ b/tests/test_cwt.py @@ -681,3 +681,150 @@ def test_cwt_decode_with_invalid_cwt_claims_in_headers(self, ctx, invalid, msg): ctx.decode(cwt, mac_key) pytest.fail("verify should fail.") assert msg in str(err.value) + + def test_cwt_claims_header_label_is_rfc9597(self, ctx): + assert COSEHeaders.CWT_CLAIMS == 15 + cose = COSE.new(alg_auto_inclusion=True, kid_auto_inclusion=True) + payload = dumps({CWTClaims.ISS: "https://as.example"}) + protected = {COSEHeaders.CWT_CLAIMS: {CWTClaims.ISS: "https://as.example"}} + mac_key = COSEKey.from_symmetric_key(alg="HS256", kid="01") + encoded = cbor2.loads(cose.encode_and_mac(payload, mac_key, protected)) + p = cbor2.loads(encoded.value[0]) + assert 15 in p and 13 not in p + assert p[15] == {CWTClaims.ISS: "https://as.example"} + + @pytest.mark.parametrize( + "value", + [ + b"\xd8\x3d\x84", # kcwt (RFC 9528) value, not a CWT Claims map + {CWTClaims.ISS: "https://asx.example"}, # draft-ietf-cose-cwt-claims-in-headers label + ], + ) + def test_cwt_decode_ignores_label_13(self, ctx, value): + cose = COSE.new(alg_auto_inclusion=True, kid_auto_inclusion=True) + payload = dumps({CWTClaims.ISS: "https://as.example"}) + mac_key = COSEKey.from_symmetric_key(alg="HS256", kid="01") + cwt = cose.encode_and_mac(payload, mac_key, {13: value}) + decoded = ctx.decode(cwt, mac_key) + assert decoded[CWTClaims.ISS] == "https://as.example" + + def test_cwt_decode_with_cwt_claims_in_unprotected_header(self, ctx): + cose = COSE.new(alg_auto_inclusion=True, kid_auto_inclusion=True) + payload = dumps({CWTClaims.ISS: "https://as.example", CWTClaims.SUB: "someone"}) + mac_key = COSEKey.from_symmetric_key(alg="HS256", kid="01") + unprotected = {COSEHeaders.CWT_CLAIMS: {CWTClaims.ISS: "https://as.example"}} + cwt = cose.encode_and_mac(payload, mac_key, unprotected=unprotected) + decoded = ctx.decode(cwt, mac_key) + assert decoded[CWTClaims.ISS] == "https://as.example" + + def test_cwt_decode_with_invalid_cwt_claims_in_unprotected_header(self, ctx): + cose = COSE.new(alg_auto_inclusion=True, kid_auto_inclusion=True) + payload = dumps({CWTClaims.ISS: "https://as.example", CWTClaims.SUB: "someone"}) + mac_key = COSEKey.from_symmetric_key(alg="HS256", kid="01") + unprotected = {COSEHeaders.CWT_CLAIMS: {CWTClaims.ISS: "https://asx.example"}} + cwt = cose.encode_and_mac(payload, mac_key, unprotected=unprotected) + with pytest.raises(VerifyError) as err: + ctx.decode(cwt, mac_key) + pytest.fail("verify should fail.") + assert f"The CWT claim({CWTClaims.ISS}) value in unprotected header does not match the values in the payload." in str( + err.value + ) + + def test_cwt__verify_with_cwt_claims_in_both_headers(self, ctx): + claims = {CWTClaims.ISS: "https://as.example"} + hdr = {COSEHeaders.CWT_CLAIMS: {CWTClaims.ISS: "https://as.example"}} + with pytest.raises(ValueError) as err: + ctx._verify(claims, [(hdr, hdr)]) + pytest.fail("_verify should fail.") + assert "must occur only once in either the protected or unprotected header." in str(err.value) + + @pytest.mark.parametrize("invalid", [b"", [], 1, "x", None]) + @pytest.mark.parametrize("protected", [True, False]) + def test_cwt_decode_with_non_map_cwt_claims_in_headers(self, ctx, invalid, protected): + cose = COSE.new(alg_auto_inclusion=True, kid_auto_inclusion=True) + payload = dumps({CWTClaims.ISS: "https://as.example"}) + mac_key = COSEKey.from_symmetric_key(alg="HS256", kid="01") + hdr = {COSEHeaders.CWT_CLAIMS: invalid} + if protected: + cwt = cose.encode_and_mac(payload, mac_key, protected=hdr) + else: + cwt = cose.encode_and_mac(payload, mac_key, unprotected=hdr) + with pytest.raises(ValueError) as err: + ctx.decode(cwt, mac_key) + pytest.fail("decode should fail.") + assert "The CWT Claims header parameter(15) should be map." in str(err.value) + + def test_cwt_decode_with_cwt_claims_absent_from_payload_or_structured(self, ctx): + cose = COSE.new(alg_auto_inclusion=True, kid_auto_inclusion=True) + aud = ["https://rs1.example", "https://rs2.example"] + cnf = {1: {1: 4, 2: b"kid", -1: b"\x00" * 16}} + payload = dumps({CWTClaims.ISS: "https://as.example", CWTClaims.AUD: aud, CWTClaims.CNF: cnf}) + protected = { + COSEHeaders.CWT_CLAIMS: { + CWTClaims.SUB: "only-in-header", + CWTClaims.AUD: aud, + CWTClaims.CNF: cnf, + } + } + mac_key = COSEKey.from_symmetric_key(alg="HS256", kid="01") + cwt = cose.encode_and_mac(payload, mac_key, protected) + decoded = ctx.decode(cwt, mac_key) + assert CWTClaims.SUB not in decoded + assert decoded[CWTClaims.AUD] == aud + + @pytest.mark.parametrize("header_value", [1.0, True, "1", b"\x01", [1]]) + def test_cwt_decode_with_cwt_claims_in_headers_not_identical_type(self, ctx, header_value): + cose = COSE.new(alg_auto_inclusion=True, kid_auto_inclusion=True) + payload = dumps({CWTClaims.ISS: "https://as.example", -70001: 1}) + protected = {COSEHeaders.CWT_CLAIMS: {-70001: header_value}} + mac_key = COSEKey.from_symmetric_key(alg="HS256", kid="01") + cwt = cose.encode_and_mac(payload, mac_key, protected) + with pytest.raises(VerifyError) as err: + ctx.decode(cwt, mac_key) + pytest.fail("decode should fail.") + assert "The CWT claim(-70001) value in protected header does not match the values in the payload." in str(err.value) + + def test_cwt_decode_with_cwt_claims_in_headers_map_order_independent(self, ctx): + cose = COSE.new(alg_auto_inclusion=True, kid_auto_inclusion=True) + payload = dumps({CWTClaims.ISS: "https://as.example", -70001: {"a": 1, "b": 2}}) + protected = {COSEHeaders.CWT_CLAIMS: {-70001: {"b": 2, "a": 1}}} + mac_key = COSEKey.from_symmetric_key(alg="HS256", kid="01") + cwt = cose.encode_and_mac(payload, mac_key, protected) + decoded = ctx.decode(cwt, mac_key) + assert decoded[-70001] == {"a": 1, "b": 2} + + def test_cwt_decode_with_cwt_claims_in_headers_with_no_verify(self, ctx): + cose = COSE.new(alg_auto_inclusion=True, kid_auto_inclusion=True) + payload = dumps({CWTClaims.ISS: "https://as.example"}) + protected = {COSEHeaders.CWT_CLAIMS: {CWTClaims.ISS: "https://asx.example"}} + mac_key = COSEKey.from_symmetric_key(alg="HS256", kid="01") + cwt = cose.encode_and_mac(payload, mac_key, protected) + decoded = ctx.decode(cwt, mac_key, no_verify=True) + assert decoded[CWTClaims.ISS] == "https://as.example" + + @pytest.mark.parametrize( + "outer_iss, inner_iss, where", + [ + ("https://as.example", "https://as.example", None), + ("https://asx.example", None, "outer"), + (None, "https://asx.example", "inner"), + ("https://as.example", "https://asx.example", "inner"), + ], + ) + def test_cwt_decode_nested_with_cwt_claims_in_headers(self, ctx, outer_iss, inner_iss, where): + cose = COSE.new(alg_auto_inclusion=True, kid_auto_inclusion=True) + payload = dumps({CWTClaims.ISS: "https://as.example", CWTClaims.SUB: "someone"}) + mac_key = COSEKey.from_symmetric_key(alg="HS256", kid="01") + enc_key = COSEKey.from_symmetric_key(alg="A128GCM", kid="02") + inner_p = {COSEHeaders.CWT_CLAIMS: {CWTClaims.ISS: inner_iss}} if inner_iss else {} + outer_p = {COSEHeaders.CWT_CLAIMS: {CWTClaims.ISS: outer_iss}} if outer_iss else {} + inner = cose.encode_and_mac(payload, mac_key, inner_p) + token = cose.encode_and_encrypt(inner, enc_key, outer_p) + if where is None: + decoded = ctx.decode(token, [enc_key, mac_key]) + assert decoded[CWTClaims.ISS] == "https://as.example" + return + with pytest.raises(VerifyError) as err: + ctx.decode(token, [enc_key, mac_key]) + pytest.fail("decode should fail.") + assert f"The CWT claim({CWTClaims.ISS}) value in protected header does not match" in str(err.value) diff --git a/tests/test_cwt_sample.py b/tests/test_cwt_sample.py index 12e1a6a0..1cd7937e 100644 --- a/tests/test_cwt_sample.py +++ b/tests/test_cwt_sample.py @@ -989,7 +989,7 @@ def test_sample_cwt_with_cwt_claims_in_headers(self): }, ) protected = { - COSEHeaders.CWT_CLAIMS: { # 13 + COSEHeaders.CWT_CLAIMS: { # 15 CWTClaims.ISS: "coap://as.example.com", } }