Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------------
Expand Down
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -1562,9 +1562,25 @@ 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.
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.

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.
**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.

See [RFC9597 Sections 2–4](https://www.rfc-editor.org/rfc/rfc9597.html#section-2) for recommendations on placing the
parameter in the protected header, privacy, and security considerations.

```py
from cwt import COSE, COSEHeaders, COSEKey, CWT, CWTClaims
Expand All @@ -1585,7 +1601,7 @@ payload = cbor2.dumps(
},
)
protected = {
COSEHeaders.CWT_CLAIMS: { # 13
COSEHeaders.CWT_CLAIMS: { # 15
CWTClaims.ISS: "coap://as.example.com",
}
}
Expand All @@ -1599,6 +1615,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).
Expand Down Expand Up @@ -1800,9 +1819,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)
Expand Down
52 changes: 43 additions & 9 deletions cwt/cwt.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -320,6 +322,12 @@ def decode(
"""
Verifies and decodes CWT.

Unless ``no_verify`` is set, if a CWT Claims header parameter
(`RFC 9597 <https://www.rfc-editor.org/rfc/rfc9597.html>`_, 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
Expand All @@ -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]):
Expand Down Expand Up @@ -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.")

Expand All @@ -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]):
Expand Down
2 changes: 1 addition & 1 deletion cwt/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 147 additions & 0 deletions tests/test_cwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion tests/test_cwt_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
}
Expand Down
Loading