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", } }