Repository navigation
Description
This comes from a duplicate-map-key conformance check across four COSE/CWT libraries; go-cose, google/coset and ldclabs/cose all reject these bytes.
Summary
When decoding a COSE message, python-cwt parses the protected-header bstr with a plain cbor2.loads and performs no duplicate-key policing. A protected header whose map contains the same label more than once is accepted, with the CBOR decoder silently keeping the last value (last-wins), instead of being rejected as malformed.
RFC 9052 §3 (Header Parameters): "Labels in each of the maps MUST be unique." and "When processing messages, if a label appears multiple times, the message MUST be rejected as malformed." (§9 restates this.) go-cose, google/coset and ldclabs/cose all enforce this and reject the same bytes.
Because _loads is shared, the same last-wins behaviour also applies to the CWT claims map — but that is a robustness/interop note, not a spec violation (RFC 8392 defines no duplicate-key rule for claims). The MUST-reject violation is specifically the protected-header case.
Mechanism (file:line at commit 2173bbb)
cwt/cbor_processor.py:15-18—CBORProcessor._loads→return cbor2.loads(s); cbor2's default decoder is last-wins (noDupMapKeyEnforced). Shared by header and claims decode.cwt/cose.py:456→cwt/cose.py:653-665(line 657p = self._loads(protected)) —_decode_headersdecodes the raw protected bstr before any signature/MAC verification, so a duplicate label is collapsed last-wins and never flagged. Applies to every tag (Sign1/Sign, Mac0/Mac, Encrypt0/Encrypt).cwt/cwt.py:336,:343— claims decoded with the same_loads(last-wins; robustness note).tests/test_cose.py:1145-1149— the negative case for a duplicated protected-header label is commented out; no test exercises duplicate-key rejection on decode.
Reproduction (python-cwt 3.3.0, cbor2 6.2.1)
import cwt
from cwt import COSE, COSEKey
from cbor2 import CBORTag, dumps, loads
# protected-header map with the SAME integer label 1 (alg) twice: {1:4, 1:5}
inner = b"\xa2" + dumps(1) + dumps(4) + dumps(1) + dumps(5)
assert loads(inner) == {1: 5} # cbor2 default decode = last-wins
key = COSEKey.from_symmetric_key(alg="HS256", kid="poc")
tag = key.sign(dumps(["MAC0", inner, b"", b"payload"])) # MAC over the raw duplicate-key bytes
wire = dumps(CBORTag(17, [inner, {}, b"payload", tag])) # COSE_Mac0 (tag 17)
p, u, pl = COSE.new().decode_with_headers(wire, key)
print("python-cwt:", "ACCEPTED", "protected =", p)Output:
python-cwt: ACCEPTED protected = {1: 5}
The duplicate label 1 is silently resolved last-wins and the message verifies — no malformed rejection. The same holds for COSE_Sign1 (tag 18). Over the identical on-wire bytes, peer libraries reject:
go-cose Sign1Message.UnmarshalCBOR -> cbor: found duplicate map key … value:1
coset CoseSign1::from_tagged_slice -> duplicate map key
Severity (honest)
Low — conformance/robustness. This is acceptance of malformed COSE, not a verification bypass, forgery, or claim injection: the duplicated label lives inside the cryptographically protected bytes, so it cannot be introduced into a message signed by an honest party without breaking the signature. The gap is the missing malformed-input rejection that RFC 9052 §3 mandates and that peer libraries implement. No CVSS requested — left for maintainers to score if they wish.
Anticipated objections
- "The CWT claims last-wins isn't an RFC violation." Conceded — RFC 8392 has no duplicate-key rule for claims; that part is a robustness note. The protected-header case is the MUST violation (RFC 9052 §3 / §9) and stands on its own.
- "The signature still verifies, so nothing is wrong." §3 is a MUST-reject regardless of whether the message is otherwise valid; three peer implementations reject it. The point is malformed-input handling.
- "cbor2 drops the duplicate, not us." cbor2 offers the enforcement toggle; the shared
_loadsdoes not opt in. go-cose/ldclabs flip exactly this flag (DupMapKeyEnforcedAPF); coset polices it by hand. - "An attacker can't exploit this." Agreed, and stated — hence Low. Reported as a spec-conformance fix, not a vulnerability.
Suggested remediation
- Enforce duplicate-key rejection when decoding the protected header — decode
value[0]in_decode_headerswith cbor2's duplicate-key enforcement (or a tag-hook that counts labels) and raise the existing malformed-header error on a repeat. - Optionally police the CWT claims decode too (defense-in-depth; not required by RFC 8392).
- Re-enable a decode-side negative test (duplicate protected-header label → reject) in place of the commented-out encode case at
tests/test_cose.py:1145-1149.
Re-pin to upstream HEAD and re-run before acting (pin used: 2173bbb).
Prepared with AI assistance. All file:line references, the RFC 9052 §3 / §9 clauses, and the PoC output were verified against the primary sources (the python-cwt, go-cose and coset trees at the pinned commits and the RFC 9052 text). — Nobuo Otoi (ba-cybersecurity.com)
Activity
dajiaji commentedon Nov 9, 2026
@otoinobuo Thanks for the report! Confirmed and fixed in #718: duplicate map keys are now rejected on decode (requires cbor2 >= 6.1.0).
otoinobuo commentedon Nov 9, 2026
Thanks for the quick fix! Verified on main (623f320 / #718): duplicate protected-header labels in COSE_Mac0 are now rejected (
Duplicate map key: 1), and the 3.3.0 release still accepts them as reported.