Skip to content

python-cwt accepts duplicate map keys in COSE protected headers; RFC 9052 §3 requires rejection #710

Closed

Description

@otoinobuo

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 (no DupMapKeyEnforced). Shared by header and claims decode.
  • cwt/cose.py:456 → cwt/cose.py:653-665 (line 657 p = self._loads(protected)) — _decode_headers decodes 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 _loads does 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_headers with 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

  1. dajiaji commented on Nov 9, 2026

    @dajiaji
    Owner

    @otoinobuo Thanks for the report! Confirmed and fixed in #718: duplicate map keys are now rejected on decode (requires cbor2 >= 6.1.0).

  2. otoinobuo commented on Nov 9, 2026

    @otoinobuo
    Author

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions