diff --git a/docs/source/pcapkit/protocols/application/dtls.rst b/docs/source/pcapkit/protocols/application/dtls.rst index a0961f3358..99da22d525 100644 --- a/docs/source/pcapkit/protocols/application/dtls.rst +++ b/docs/source/pcapkit/protocols/application/dtls.rst @@ -56,19 +56,21 @@ Connection IDs -------------- A ``tls12_cid`` record carries a connection ID between its sequence number -and its length (:rfc:`9146#section-4`), and the ID's length is negotiated -rather than carried. -:class:`~pcapkit.protocols.application.dtls.DTLSContext` resolves it, in this -order: - -* a connection ID the record begins with that a ``ClientHello`` or - ``ServerHello`` ``connection_id`` extension negotiated earlier in the - capture, each peer naming the one it receives (:rfc:`9146#section-3`); +and its length (:rfc:`9146#section-4`), and a DTLS 1.3 unified header with +its ``C`` bit set carries one between its first octet and its sequence number +(:rfc:`9147#section-4`). Either way the ID's length is negotiated rather than +carried. :class:`~pcapkit.protocols.application.dtls.DTLSContext` resolves +it for both, in this order: + +* a connection ID that a ``ClientHello`` or ``ServerHello`` + ``connection_id`` extension negotiated earlier in the capture, each peer + naming the one it receives (:rfc:`9146#section-3`, + :rfc:`9147#section-9.1`), and that the record's ID begins with; * the ``cid_length`` the caller gives :class:`~pcapkit.protocols.application.dtls.DTLSContext`. Without either the record stays :class:`~pcapkit.protocols.misc.raw.Raw`. -A dissected one reports its epoch, sequence number, connection ID and length, +A dissected one reports its connection ID alongside its other header fields, and keeps its encrypted content as octets. The context reaches DTLS through :mod:`pcapkit.corekit.context`: @@ -93,7 +95,7 @@ Matching by value relies on IDs being self-delineating A rebuild parses each layer's octets again, so ``from_data`` takes the same ``__context__``. Without it the octets still rebuild exactly, and -``DTLS.from_data`` keeps its chain, but a ``tls12_cid`` record under a +``DTLS.from_data`` keeps its chain, but a record with a connection ID under a rebuilt UDP layer reads as :class:`~pcapkit.protocols.misc.raw.Raw`. Records in One Datagram @@ -111,9 +113,8 @@ exceptions: :class:`~pcapkit.protocols.misc.raw.Raw` together with everything after it. * Octets that open no whole record stay raw, with no error: an unsupported content type, a legacy version outside ``0xFExx``, a header cut short, a - length past the end of the datagram, a unified header with a connection - ID, or a ``tls12_cid`` record whose connection ID is not known, either of - which leaves the record's end unknown. + length past the end of the datagram, or a connection ID whose length is + not known, which leaves the record's end unknown. * A parse told to stop at DTLS (``protocol='DTLS'`` or ``layer='Application'``) keeps the rest of the datagram raw. * Each record is a layer, so :data:`~pcapkit.utilities.config.FRAME_LAYER_LIMIT` @@ -131,13 +132,14 @@ message, and :func:`~pcapkit.foundation.analysis.dtls.dtls_handshake` decodes it with :meth:`~pcapkit.protocols.application.dtls.DTLS.analyze_handshake`, which reads it with :meth:`~pcapkit.protocols.application.dtls.DTLS.read_handshake`. -**A unified-header record with a connection ID is not decoded** -(:rfc:`9147#section-4.1`, :issue:`2467`). Its length is negotiated, not carried -in the record, so it degrades to :class:`~pcapkit.protocols.misc.raw.Raw`. - **A connection ID negotiated in a fragmented hello is not learned**: the message is not decoded here, so its ``connection_id`` extension is not read. +**A connection ID a DTLS 1.3** ``NewConnectionId`` **issues is not learned** +(:rfc:`9147#section-9`, :issue:`2625`): the message is encrypted, so only the +caller can give it to +:class:`~pcapkit.protocols.application.dtls.DTLSContext`. + **A unified-header record carries only the low two bits of the epoch**, and its sequence number is header-protected, so the values it reports are the ones on the wire. diff --git a/pcapkit/protocols/application/dtls.py b/pcapkit/protocols/application/dtls.py index 023c74e254..308e96295e 100644 --- a/pcapkit/protocols/application/dtls.py +++ b/pcapkit/protocols/application/dtls.py @@ -20,16 +20,19 @@ -------------- A ``tls12_cid`` record carries a connection ID between its sequence number -and its length (:rfc:`9146#section-4`), and the ID's length is negotiated -rather than carried. :class:`DTLSContext` resolves it, in this order: - -* a connection ID the record begins with that a ``ClientHello`` or - ``ServerHello`` ``connection_id`` extension negotiated earlier in the - capture, each peer naming the one it receives (:rfc:`9146#section-3`); +and its length (:rfc:`9146#section-4`), and a DTLS 1.3 unified header with +its ``C`` bit set carries one between its first octet and its sequence number +(:rfc:`9147#section-4`). Either way the ID's length is negotiated rather than +carried. :class:`DTLSContext` resolves it for both, in this order: + +* a connection ID that a ``ClientHello`` or ``ServerHello`` + ``connection_id`` extension negotiated earlier in the capture, each peer + naming the one it receives (:rfc:`9146#section-3`, + :rfc:`9147#section-9.1`), and that the record's ID begins with; * the ``cid_length`` the caller gives :class:`DTLSContext`. Without either the record stays :class:`~pcapkit.protocols.misc.raw.Raw`. -A dissected one reports its epoch, sequence number, connection ID and length, +A dissected one reports its connection ID alongside its other header fields, and keeps its encrypted content as octets. The context reaches DTLS through :mod:`pcapkit.corekit.context`: @@ -49,7 +52,7 @@ A rebuild parses each layer's octets again, so ``from_data`` takes the same ``__context__``. Without it the octets still rebuild exactly, and -``DTLS.from_data`` keeps its chain, but a ``tls12_cid`` record under a +``DTLS.from_data`` keeps its chain, but a record with a connection ID under a rebuilt UDP layer reads as :class:`~pcapkit.protocols.misc.raw.Raw`. Records in One Datagram @@ -66,9 +69,8 @@ :class:`~pcapkit.protocols.misc.raw.Raw` together with everything after it. * Octets that open no whole record stay raw, with no error: an unsupported content type, a legacy version outside ``0xFExx``, a header cut short, a - length past the end of the datagram, a unified header with a connection - ID, or a ``tls12_cid`` record whose connection ID is not known, either of - which leaves the record's end unknown. + length past the end of the datagram, or a connection ID whose length is + not known, which leaves the record's end unknown. * A parse told to stop at DTLS (``protocol='DTLS'`` or ``layer='Application'``) keeps the rest of the datagram raw. * Each record is a layer, so :data:`~pcapkit.utilities.config.FRAME_LAYER_LIMIT` @@ -87,13 +89,13 @@ :meth:`DTLS.analyze_handshake`, which reads it with :meth:`DTLS.read_handshake`. Only the first record of each datagram is reassembled. -**A unified-header record with a connection ID is not decoded** -(:rfc:`9147#section-4.1`). Its length is negotiated, not carried in the -record, so it degrades to :class:`~pcapkit.protocols.misc.raw.Raw`. - **A connection ID negotiated in a fragmented hello is not learned**: the message is not decoded here, so its ``connection_id`` extension is not read. +**A connection ID a DTLS 1.3** ``NewConnectionId`` **issues is not learned** +(:rfc:`9147#section-9`, :issue:`2625`): the message is encrypted, so only the +caller can give it to :class:`DTLSContext`. + **A unified-header record carries only the low two bits of the epoch**, and its sequence number is header-protected, so the values it reports are the ones on the wire. @@ -188,17 +190,59 @@ def _is_unified_header(octet: int) -> bool: return octet >> 5 == _UNIFIED_PREFIX -def _ciphertext_header_length(flags: 'dict[str, int]') -> 'int': +def _unified_flags(octet: 'int') -> 'dict[str, int]': + """The ``C``, ``S`` and ``L`` bits of a unified header's first octet, ``001CSLEE``. + + Args: + octet: First octet of the record. + + Returns: + The bits, keyed as the header schema's ``flags`` keys them. + + """ + return {'connection_id': octet >> 4 & 1, 'sequence_long': octet >> 3 & 1, 'length_present': octet >> 2 & 1} + + +def _ciphertext_header_length(flags: 'dict[str, int]', cid_length: 'int' = 0) -> 'int': """Length of a unified header, from its first octet's decoded flags. Args: flags: Decoded first octet, as the header schema holds it. + cid_length: Length of the connection ID the header carries, if any. Returns: - First octet, sequence number and, if present, the length field. + First octet, connection ID, sequence number and, if present, the + length field. """ - return 1 + (2 if flags['sequence_long'] else 1) + (2 if flags['length_present'] else 0) + return 1 + cid_length + (2 if flags['sequence_long'] else 1) + (2 if flags['length_present'] else 0) + + +def _record_cid_length(data: 'bytes', context: 'Optional[DTLSContext]') -> 'Optional[int]': + """Length of the connection ID of the record ``data`` opens with, as ``context`` matches it. + + A ``tls12_cid`` record's ID follows its sequence number, just before its + length (:rfc:`9146#section-4`). A unified header's follows its first + octet, before its sequence number and the length, if present + (:rfc:`9147#section-4`). + + Args: + data: A ``tls12_cid`` record, or a unified-header one with ``C`` set. + context: Connection IDs known to the parse, if any. + + Returns: + The connection ID's length, or :data:`None` when ``context`` has no + candidate for it. + + """ + if context is None: + return None + if _is_unified_header(data[0]): + flags = _unified_flags(data[0]) + return context.match(data[1:], sequence_length=2 if flags['sequence_long'] else 1, + length_present=bool(flags['length_present']), + min_content_length=_CIPHERTEXT_MIN_LENGTH) + return context.match(data[_CID_OFFSET:]) def _opens_whole_record(data: 'bytes', context: 'Optional[DTLSContext]') -> 'bool': @@ -209,11 +253,10 @@ def _opens_whole_record(data: 'bytes', context: 'Optional[DTLSContext]') -> 'boo `: a content type a genuine record carries, a ``0xFExx`` legacy version, and a fragment that ends inside ``data``. A ``tls12_cid`` header needs DTLS 1.2's version - and a connection ID ``context`` knows the length of. A unified header must - carry no connection ID, whose negotiated length leaves the record's end - unknown, and a fragment that ends inside ``data``; without a length it runs - to the end of ``data``, as the last record of a datagram does - (:rfc:`9147#section-4`). + and a connection ID ``context`` knows the length of. A unified header needs + the same of its connection ID, if it carries one, and a fragment that ends + inside ``data``; without a length it runs to the end of ``data``, as the + last record of a datagram does (:rfc:`9147#section-4`). Args: data: Octets after a record. @@ -226,16 +269,17 @@ def _opens_whole_record(data: 'bytes', context: 'Optional[DTLSContext]') -> 'boo if not data: return False if _is_unified_header(data[0]): - # NOTE: the ``C``, ``S`` and ``L`` bits of ``001CSLEE``, as the header schema reads them. - flags = {'connection_id': data[0] >> 4 & 1, 'sequence_long': data[0] >> 3 & 1, - 'length_present': data[0] >> 2 & 1} - header = _ciphertext_header_length(flags) - if flags['connection_id'] or len(data) < header: + flags = _unified_flags(data[0]) + cid_length = _record_cid_length(data, context) if flags['connection_id'] else 0 + if cid_length is None: + return False + header = _ciphertext_header_length(flags, cid_length) + if len(data) < header: return False if not flags['length_present']: return True elif data[0] == Enum_ContentType.tls12_cid: - cid_length = None if context is None else context.match(data[_CID_OFFSET:]) + cid_length = _record_cid_length(data, context) if cid_length is None or int.from_bytes(data[1:3], 'big') != _DTLSV1_2_VERSION: return False header = _LEGACY_HEADER_LENGTH + cid_length @@ -250,13 +294,15 @@ def _opens_whole_record(data: 'bytes', context: 'Optional[DTLSContext]') -> 'boo class DTLSContext(ProtocolContext): - """Connection IDs for the :class:`DTLS` records that carry one (:rfc:`9146`). + """Connection IDs for the :class:`DTLS` records that carry one (:rfc:`9146`, :rfc:`9147`). - A ``tls12_cid`` record does not carry its connection ID's length, so - :meth:`match` finds it: from the connection IDs registered here, then from - :attr:`cid_length`. :class:`DTLS` registers those a ``ClientHello`` or - ``ServerHello`` negotiates, and those of the records a constructed one - carries as its payload. + Neither a ``tls12_cid`` record nor a DTLS 1.3 unified header carries its + connection ID's length, so :meth:`match` finds it: from the connection IDs + registered here, then from :attr:`cid_length`. :class:`DTLS` registers + those a ``ClientHello`` or ``ServerHello`` negotiates, and those of the + records a constructed one carries as its payload. Those a DTLS 1.3 + ``NewConnectionId`` issues are encrypted, so only the caller can register + them (:rfc:`9147#section-9`). The IDs belong to the instance: a new one starts with none, one reused across parses or extractions keeps what it has learned, and nothing is @@ -265,7 +311,11 @@ class DTLSContext(ProtocolContext): Matching by value relies on IDs being self-delineating (:rfc:`9146#section-4`): with ``c1c2c3c4`` registered, a record whose ID - is ``c1c2c3c400010203`` reads as carrying ``c1c2c3c4``. + is ``c1c2c3c400010203`` reads as carrying ``c1c2c3c4``. Where one + registered ID is a prefix of another, a record carrying the shorter can + read as carrying the longer, when the octets after it repeat the longer + one's rest and the record still fits; a unified header must also leave + room for its record-number sample, which rules most such readings out. Args: *connection_ids: Connection IDs known out of band. @@ -315,17 +365,30 @@ def register(self, cid: 'bytes') -> 'None': if cid not in self.__connection_ids__: self.__connection_ids__.append(cid) - def match(self, octets: 'bytes') -> 'Optional[int]': + def match(self, octets: 'bytes', *, sequence_length: 'int' = 0, length_present: 'bool' = True, + min_content_length: 'int' = 0) -> 'Optional[int]': """Find the length of the connection ID that ``octets`` opens with. Each registered connection ID that ``octets`` begins with is a candidate, longest first, and :attr:`cid_length` comes last. The first - whose record -- a 16-bit length after the ID, then that many octets -- - ends inside ``octets`` is the answer. Failing that it is the first - candidate, so that reading the record reports what runs past the end. + whose record ends inside ``octets`` and carries at least + ``min_content_length`` octets of content is the answer. After the ID + come ``sequence_length`` octets, then a 16-bit length and that many + octets, or, without the length, the rest of ``octets``. Failing that it + is the first candidate, so that reading the record reports what is + wrong with it. Args: - octets: A ``tls12_cid`` record from its connection ID on. + octets: A record from its connection ID on. + sequence_length: Octets between the ID and the length: none in a + ``tls12_cid`` record (:rfc:`9146#section-4`), and a unified + header's sequence number (:rfc:`9147#section-4`). + length_present: Whether the record carries its length, as a unified + header's ``L`` bit says; a ``tls12_cid`` record always does. + min_content_length: Fewest content octets a record may carry: a + unified header's record-number sample (:rfc:`9147#section-4.2.3`). + A ``tls12_cid`` record's minimum depends on its cipher suite, + so none is assumed for it. Returns: The connection ID's length, or :data:`None` with no candidate. @@ -336,8 +399,9 @@ def match(self, octets: 'bytes') -> 'Optional[int]': if self.__cid_length__ is not None: candidates.append(self.__cid_length__) for length in candidates: - end = length + 2 - if end + int.from_bytes(octets[length:end], 'big') <= len(octets): + end = length + sequence_length + (2 if length_present else 0) + content = int.from_bytes(octets[end - 2:end], 'big') if length_present else len(octets) - end + if min_content_length <= content and end + content <= len(octets): return length return candidates[0] if candidates else None @@ -393,12 +457,12 @@ def length(self) -> 'int': """Header length of current protocol -- the record header, the fragment excluded. Fixed at 13 octets for a legacy record, and the connection ID's length more for a ``tls12_cid`` one; a unified - header's length follows from its first octet. + header's length follows from its first octet and its connection ID. """ schema = getattr(self, '__header__', None) if isinstance(schema, Schema_DTLSCiphertext): - return _ciphertext_header_length(schema.flags) + return _ciphertext_header_length(schema.flags, len(schema.cid or b'')) if isinstance(schema, Schema_DTLSCIDCiphertext): return _LEGACY_HEADER_LENGTH + len(schema.cid) return _LEGACY_HEADER_LENGTH @@ -479,9 +543,8 @@ def make(self, from it. payload: Octets after the record, e.g. a further record coalesced in the same buffer. The connection IDs of the - ``tls12_cid`` records a :class:`DTLS` payload carries are - registered, so that its octets parse again - (:class:`DTLSContext`). + records a :class:`DTLS` payload carries are registered, so + that its octets parse again (:class:`DTLSContext`). ciphertext: Make a unified-header record (:rfc:`9147#section-4.1`) instead of a legacy one; ``content_type``, ``version`` and ``data`` do not apply. @@ -490,15 +553,17 @@ def make(self, length_present: Whether a unified-header record carries its length; if not, it runs to the end of the datagram. cid: Connection ID of a ``tls12_cid`` record (:rfc:`9146#section-4`), - 1 to 255 octets; no other record takes one. + which needs one, or of a unified-header record, which sets its + ``C`` bit (:rfc:`9147#section-4`); 1 to 255 octets. No other + record takes one. **kwargs: Arbitrary keyword arguments. Returns: Constructed packet data. Raises: - ProtocolError: If ``cid`` is missing or invalid for a ``tls12_cid`` - record, or given for any other. + ProtocolError: If ``cid`` is invalid, missing for a ``tls12_cid`` + record, or given for a legacy-format one of another type. StructError: If a record-header field, or a field of ``content``, does not fit its width. FieldValueError: If a unified-header record's ``epoch`` does not @@ -507,8 +572,9 @@ def make(self, """ self._learn_payload_connection_ids(payload) if ciphertext: - if cid is not None: - raise ProtocolError(f'{self.alias}: invalid connection ID: only a tls12_cid record carries one') + if cid is not None and not _is_cid(cid): + raise ProtocolError(f'{self.alias}: invalid connection ID: {cid!r} ' + '(a unified header carries 1 to 255 octets)') # NOTE: packed first for the reason below; ``epoch`` is a bit sub-field the schema refuses itself. self._make_pack(sequence_number, size=2 if sequence_long else 1) if length_present: @@ -516,11 +582,12 @@ def make(self, return Schema_DTLSCiphertext( flags={ 'fixed': _UNIFIED_PREFIX, - 'connection_id': 0, + 'connection_id': int(cid is not None), 'sequence_long': int(sequence_long), 'length_present': int(length_present), 'epoch': epoch, }, + cid=cid, sequence_number=sequence_number, length=len(content) if length_present else None, fragment=content, @@ -552,7 +619,8 @@ def make(self, payload=payload, ) if cid is not None: - raise ProtocolError(f'{self.alias}: invalid connection ID: only a tls12_cid record carries one') + raise ProtocolError(f'{self.alias}: invalid connection ID: of the legacy-format records, ' + 'only a tls12_cid one carries one') return Schema_DTLS( content_type=content_type_, version=version, @@ -569,9 +637,10 @@ def unpack(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS A unified-header record (:rfc:`9147#section-4.1`) is parsed by :class:`~pcapkit.protocols.schema.application.dtls.DTLSCiphertext`, and a ``tls12_cid`` one (:rfc:`9146#section-4`) by - :class:`~pcapkit.protocols.schema.application.dtls.DTLSCIDCiphertext` - with the connection ID length :class:`DTLSContext` finds, rather than - the legacy header; every other record uses the inherited behaviour. + :class:`~pcapkit.protocols.schema.application.dtls.DTLSCIDCiphertext`, + rather than the legacy header; every other record uses the inherited + behaviour. Either schema reads a connection ID with the length + :class:`DTLSContext` finds. Args: length: Length of packet data. @@ -581,28 +650,36 @@ def unpack(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLS Parsed packet data. Raises: - ProtocolError: If a ``tls12_cid`` record's connection ID length is - not known. + ProtocolError: If a record's connection ID length is not known. """ octets = self._data[:1] - if cast('Optional[Schema_DTLS]', self.__header__) is not None or not octets: + header = cast('Optional[Schema_DTLS | Schema_DTLSCiphertext]', self.__header__) + if header is not None or not octets: + if isinstance(header, Schema_DTLSCiphertext) and header.cid is not None: + # NOTE: a made header holding raw items is read again from its octets (#1312), + # which do not carry the connection ID's length. + kwargs['__packet__'] = {**kwargs.get('__packet__', {}), '__cid_length__': len(header.cid)} return super().unpack(length, **kwargs) - if _is_unified_header(octets[0]): - packet = kwargs.get('__packet__', {}) # packet data - self.__header__ = Schema_DTLSCiphertext.unpack(self._file, length, packet) # type: ignore[call-arg,misc] - return self.read(length, **kwargs) - if octets[0] == Enum_ContentType.tls12_cid: - context = self._get_context(DTLSContext) + unified = _is_unified_header(octets[0]) + if not unified and octets[0] != Enum_ContentType.tls12_cid: + return super().unpack(length, **kwargs) + if unified: + self._alias = 'DTLS 1.3' + packet = kwargs.get('__packet__', {}) # packet data + if not unified or _unified_flags(octets[0])['connection_id']: data = self._data if length is None else self._data[:length] - cid_length = None if context is None else context.match(data[_CID_OFFSET:]) + cid_length = _record_cid_length(data, self._get_context(DTLSContext)) if cid_length is None: - raise ProtocolError(f'{self.alias}: invalid format: no connection ID length is known ' - 'for a tls12_cid record (RFC 9146 section 4)') - packet = {**kwargs.get('__packet__', {}), '__cid_length__': cid_length} - self.__header__ = Schema_DTLSCIDCiphertext.unpack(self._file, length, packet) # type: ignore[call-arg,misc] + record = ('a unified-header record (RFC 9147 section 4)' if unified + else 'a tls12_cid record (RFC 9146 section 4)') + raise ProtocolError(f'{self.alias}: invalid format: no connection ID length is known for {record}') + packet = {**packet, '__cid_length__': cid_length} + if unified: + self.__header__ = Schema_DTLSCiphertext.unpack(self._file, length, packet) # type: ignore[call-arg,misc] return self.read(length, **kwargs) - return super().unpack(length, **kwargs) + self.__header__ = Schema_DTLSCIDCiphertext.unpack(self._file, length, packet) # type: ignore[call-arg,misc] + return self.read(length, **kwargs) @classmethod def read_handshake(cls, message: 'bytes') -> 'Data_DTLSHandshake': @@ -711,6 +788,7 @@ def _make_data(cls, data: 'Data_DTLS | Data_DTLSCiphertext | Data_DTLSCIDCiphert return { 'ciphertext': True, 'epoch': data.epoch, + 'cid': data.cid, 'sequence_number': data.sequence_number, 'sequence_long': data.sequence_long, 'length_present': data.length is not None, @@ -804,7 +882,7 @@ def _read_dtls_record(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> return self._decode_next_layer(dtls, -1, remaining) def _learn_payload_connection_ids(self, payload: 'Any') -> 'None': - """Register the connection IDs of the ``tls12_cid`` records in ``payload``. + """Register the connection IDs of the records in ``payload``. A constructed record parses its payload's octets again, which needs the connection IDs that the payload's records carry. @@ -816,7 +894,7 @@ def _learn_payload_connection_ids(self, payload: 'Any') -> 'None': cids = [] # type: list[bytes] layer = payload while isinstance(layer, DTLS): - if isinstance(layer.info, Data_DTLSCIDCiphertext): + if isinstance(layer.info, (Data_DTLSCIDCiphertext, Data_DTLSCiphertext)) and layer.info.cid is not None: cids.append(layer.info.cid) layer = layer.payload self._register_connection_ids(cids) @@ -824,6 +902,23 @@ def _learn_payload_connection_ids(self, payload: 'Any') -> 'None': def _read_dtls_ciphertext(self, length: 'Optional[int]' = None, **kwargs: 'Any') -> 'Data_DTLSCiphertext': # pylint: disable=unused-argument """Read a unified-header ciphertext record (:rfc:`9147#section-4.1`). + Structure of a unified header [:rfc:`9147#section-4`], whose + connection ID is as long as :class:`DTLSContext` says: + + .. code-block:: text + + 0 1 2 3 4 5 6 7 + +-+-+-+-+-+-+-+-+ + |0|0|1|C|S|L|E E| + +-+-+-+-+-+-+-+-+ + | Connection ID | if C is set + +-+-+-+-+-+-+-+-+ + | 8 or 16 bit | as S says + |Sequence Number| + +-+-+-+-+-+-+-+-+ + | 16 bit Length | if L is set + +-+-+-+-+-+-+-+-+ + Args: length: Length of packet data. **kwargs: Arbitrary keyword arguments. @@ -832,19 +927,19 @@ def _read_dtls_ciphertext(self, length: 'Optional[int]' = None, **kwargs: 'Any') Parsed packet data. Raises: - ProtocolError: If the header carries a connection ID, whose length - is negotiated and not in the record; if the header or its - declared length runs past the buffer; or if the ciphertext is - shorter than the record-number sample (:rfc:`9147#section-4.2.3`). + ProtocolError: If ``C`` is set but no connection ID is carried; if + the header or its declared length runs past the buffer; or if + the ciphertext is shorter than the record-number sample + (:rfc:`9147#section-4.2.3`). """ self._alias = 'DTLS 1.3' - schema = self.__header__ + schema = cast('Schema_DTLSCiphertext', self.__header__) flags = schema.flags available = length if length is not None else len(self) - if flags['connection_id']: - raise ProtocolError(f'{self.alias}: invalid format: connection ID records are not decoded') + if flags['connection_id'] and not _is_cid(schema.cid): + raise ProtocolError(f'{self.alias}: invalid format: the C bit is set but no connection ID is carried') needed = self.length + (schema.length or 0) if needed > available: raise ProtocolError(f'{self.alias}: invalid format: record of {needed} octets ' @@ -856,6 +951,7 @@ def _read_dtls_ciphertext(self, length: 'Optional[int]' = None, **kwargs: 'Any') dtls = Data_DTLSCiphertext( epoch=flags['epoch'], + cid=schema.cid, sequence_number=schema.sequence_number, sequence_long=bool(flags['sequence_long']), length=schema.length, diff --git a/pcapkit/protocols/data/application/dtls.py b/pcapkit/protocols/data/application/dtls.py index 270e83edfb..ca0c6ec93b 100644 --- a/pcapkit/protocols/data/application/dtls.py +++ b/pcapkit/protocols/data/application/dtls.py @@ -106,6 +106,9 @@ class DTLSCiphertext(Protocol): #: Low two bits of the epoch; the full 16-bit epoch is not on the wire. epoch: 'int' + #: Connection ID, as long as the handshake or the caller says, or + #: :data:`None` when the header carries none. + cid: 'Optional[bytes]' #: Sequence number as carried, 8 or 16 bits per :attr:`sequence_long`. It #: is header-protected, so it is the value on the wire, not the record's. sequence_number: 'int' @@ -118,7 +121,7 @@ class DTLSCiphertext(Protocol): content: 'bytes' if TYPE_CHECKING: - def __init__(self, epoch: 'int', sequence_number: 'int', sequence_long: 'bool', length: 'Optional[int]', content: 'bytes') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long + def __init__(self, epoch: 'int', cid: 'Optional[bytes]', sequence_number: 'int', sequence_long: 'bool', length: 'Optional[int]', content: 'bytes') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long @info_final diff --git a/pcapkit/protocols/schema/application/dtls.py b/pcapkit/protocols/schema/application/dtls.py index 901310b4c1..0bbe69f353 100644 --- a/pcapkit/protocols/schema/application/dtls.py +++ b/pcapkit/protocols/schema/application/dtls.py @@ -65,6 +65,10 @@ class DTLSCiphertext(Schema): :class:`DTLS `'s legacy header cannot describe it. + The record does not carry its connection ID's length + (:rfc:`9147#section-4`), so the parser gives it as the ``__cid_length__`` + key of the packet context; packing takes it from ``cid``. + """ #: Unified-header first octet: fixed prefix, ``C``, ``S``, ``L`` and ``EE``. @@ -75,6 +79,11 @@ class DTLSCiphertext(Schema): 'length_present': (5, 1), 'epoch': (6, 2), }) + #: Connection ID; absent when ``C`` is clear. + cid: 'bytes | None' = ConditionalField( + BytesField(length=lambda pkt: pkt['__cid_length__']), + condition=lambda pkt: bool(pkt['flags']['connection_id']), + ) #: Sequence number, 8 or 16 bits as ``S`` says (header-protected on the wire). sequence_number: 'int' = SwitchField( selector=lambda pkt: UInt16Field() if pkt['flags']['sequence_long'] else UInt8Field(), @@ -93,8 +102,17 @@ class DTLSCiphertext(Schema): #: buffer. Empty when :attr:`fragment` runs to the end of the buffer. payload: 'bytes' = PayloadField() + def pre_pack(self, packet: 'dict[str, Any]') -> 'None': + """Give packing the length of :attr:`cid`, which the record does not carry. + + Args: + packet: packet data + + """ + packet['__cid_length__'] = len(packet.get('cid') or b'') + if TYPE_CHECKING: - def __init__(self, flags: 'dict[str, int]', sequence_number: 'int', length: 'int | None', fragment: 'bytes', payload: 'bytes | ProtocolBase | Schema') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long + def __init__(self, flags: 'dict[str, int]', cid: 'bytes | None', sequence_number: 'int', length: 'int | None', fragment: 'bytes', payload: 'bytes | ProtocolBase | Schema') -> 'None': ... # pylint: disable=unused-argument,super-init-not-called,multiple-statements,line-too-long @schema_final diff --git a/tests/corekit/test_schema_roundtrip_unit.py b/tests/corekit/test_schema_roundtrip_unit.py index 6f1d3e21ff..83efa425f3 100644 --- a/tests/corekit/test_schema_roundtrip_unit.py +++ b/tests/corekit/test_schema_roundtrip_unit.py @@ -22,7 +22,8 @@ instance that packed is the one reported. :data:`OVERRIDES` holds the few values no field type implies (a magic number, a NUL-terminated name), and :data:`PARSE_CONTEXT` the context only the parse reads, which packing derives -for itself and so never reports missing. +for itself and so never reports missing. A bit-field subfield an override sets +is pinned rather than searched, so a condition it opens stays open. A *dispatcher* -- a schema whose ``post_process`` returns the schema it selected, such as TCP's ``_MPTCP`` -- is checked through its targets instead: @@ -86,6 +87,12 @@ 'internet.igmpv3:IGMPv3': {'type': lambda module: module.Enum_Type.IGMP_Membership_Query}, # The record does not carry the CID's length, so PARSE_CONTEXT gives it. 'application.dtls:DTLSCIDCiphertext': {'cid': CID}, + # A unified header carries a CID only with its C bit set, which the drivers + # would clear, so the flags are pinned: prefix 001, C set, S and L clear (#2625). + 'application.dtls:DTLSCiphertext': { + 'flags': {'fixed': 1, 'connection_id': 1, 'sequence_long': 0, 'length_present': 0, 'epoch': 0}, + 'cid': CID, + }, } # type: dict[str, dict[str, Any]] #: Packet context the enclosing layer gives a schema's parser and its packing does @@ -95,6 +102,7 @@ #: is agreed out of band, not carried [RFC 9146 section 4]. PARSE_CONTEXT = { 'application.dtls:DTLSCIDCiphertext': {'__cid_length__': len(CID)}, + 'application.dtls:DTLSCiphertext': {'__cid_length__': len(CID)}, } # type: dict[str, dict[str, Any]] #: Packet context a dispatch target needs from its enclosing layer. @@ -144,6 +152,13 @@ def label_of(cls: 'type') -> 'str': return f'{cls.__module__.replace(PREFIX, "")}:{cls.__qualname__}' +def overrides_of(cls: 'type') -> 'dict[str, Any]': + """``cls``'s :data:`OVERRIDES`, each callable resolved against its module.""" + module = importlib.import_module(cls.__module__) + return {name: value(module) if callable(value) else value + for name, value in OVERRIDES.get(label_of(cls), {}).items()} + + def schema_classes() -> 'dict[str, type]': """Every :class:`Schema` subclass defined under :mod:`pcapkit.protocols.schema`.""" import pcapkit.protocols.schema as package @@ -219,6 +234,7 @@ def drivers(cls: 'type') -> 'list[tuple[str, Optional[str], int]]': for fn in _callbacks(field): names |= _strings(fn) + overrides = overrides_of(cls) found = [] # type: list[tuple[str, Optional[str], int]] for name, field in cls.__fields__.items(): field = _unwrap(field) @@ -228,8 +244,9 @@ def drivers(cls: 'type') -> 'list[tuple[str, Optional[str], int]]': width = field._bit_length if field._bit_length >= 0 else 8 * max(field._length, 1) # pylint: disable=protected-access found.append((name, None, (1 << width) - 1)) elif isinstance(field, BitField): + pinned = overrides.get(name) if isinstance(overrides.get(name), dict) else {} for sub, (_, size) in field._namespace.items(): # pylint: disable=protected-access - if sub in names: + if sub in names and sub not in pinned: found.append((name, sub, (1 << size) - 1)) return found @@ -370,9 +387,7 @@ def _base_values(cls: 'type', depth: 'int', ctx: 'dict[str, Any]') -> 'dict[str, from pcapkit.corekit.fields.numbers import EnumField from pcapkit.corekit.fields.strings import BitField - module = importlib.import_module(cls.__module__) - overrides = {name: value(module) if callable(value) else value - for name, value in OVERRIDES.get(label_of(cls), {}).items()} + overrides = overrides_of(cls) values = {} # type: dict[str, Any] for name, field in cls.__fields__.items(): # An override is visible to a selector that reads it, as it would be on the wire. @@ -665,6 +680,23 @@ def test_tables_name_real_cases(self) -> None: self.assertTrue(any(label.startswith(f'dispatch/{dispatcher}/') for label in dispatch), f'{dispatcher} has no targets') + def test_an_override_pins_the_bit_field_subfields_it_sets(self) -> None: + classes = schema_classes() + for label in OVERRIDES: + for name, value in overrides_of(classes[label]).items(): + if isinstance(value, dict): + searched = {sub for field, sub, _ in drivers(classes[label]) if field == name} + self.assertEqual(sorted(searched & set(value)), [], f'{label}.{name} is overridden yet searched') + + def test_each_parse_context_entry_is_read_by_its_case(self) -> None: + """Without its entry the case fails its reparse, so the case reaches the parse that reads it.""" + classes = schema_classes() + for label in PARSE_CONTEXT: + with self.subTest(case=label): + out = build(classes[label]) + self.assertEqual(len(out), 2, f'{label} closes without its PARSE_CONTEXT entry') + self.assertEqual(out[1].status, 'REPARSE') + def test_the_census_is_the_whole_package(self) -> None: """Every schema with fields is a case, a dispatcher, or the base classes.""" plain, _ = self._cases() diff --git a/tests/protocols/application/test_dtls13_connection_id_2625_unit.py b/tests/protocols/application/test_dtls13_connection_id_2625_unit.py new file mode 100644 index 0000000000..2e3e8fe85a --- /dev/null +++ b/tests/protocols/application/test_dtls13_connection_id_2625_unit.py @@ -0,0 +1,561 @@ +# -*- coding: utf-8 -*- +"""DTLS 1.3 unified-header records with a connection ID (RFC 9147 section 4, #2625). + +A unified header with its ``C`` bit set carries a connection ID between its +first octet and its sequence number (RFC 9147 section 4, Figure 3). The ID's +length is negotiated in the ``connection_id`` extension (RFC 9146 section 3), +not carried, so ``DTLSContext`` resolves it as it does for a ``tls12_cid`` +record (#2515): from the IDs the capture's hellos negotiate, then from a +caller-supplied ``cid_length``. + +:data:`WOLFSSL` is a real exchange between wolfSSL 5.9.4's example client and +server (``-u -v 4 -s``, built with ``--enable-dtls13 --enable-dtlscid``), +recorded through a loopback UDP relay: the client asks for ``cli4`` and the +server for ``SRV-CID8``. It has the shape of RFC 9147 section 9.1's Figure 15. +tshark 4.6.9 (``-d udp.port==5684,dtls``) reads every record as :data:`TSHARK` +lists it. The other vectors follow Figure 3. + +""" +from __future__ import annotations + +import sys +import threading +import unittest +import warnings +from typing import TYPE_CHECKING + +from tests._support import close_extractor, reimport_once_per_class +from tests.protocols.application.test_dtls_connection_id_2515_unit import CCS, _dtls_layers, _ethernet_pcap, _udp +from tests.utilities._harness import capture + +if TYPE_CHECKING: + from typing import Any + +#: Test tier (#1958): checks that the exception hooks are left alone. +__tier__ = 'ALL' + +#: Connection IDs the wolfSSL client and server ask to receive. +CLIENT_CID = b'cli4' +SERVER_CID = b'SRV-CID8' + +#: The exchange, one ``(from_client, payload)`` per UDP datagram, in order: +#: ClientHello, HelloRetryRequest, ClientHello with the cookie, ServerHello, +#: then the encrypted records. +WOLFSSL = tuple((direction == 'C', bytes.fromhex(payload)) for direction, payload in ( + ('C', '16fefd0000000000000000011d010001110000000000000111fefd6a51df028ea6014c22cc84ed52a3339c8169dc1813' + 'c0c5c06300aa31e5944a7e00000004130100aa010000e3002d0003020001002b000302fefc000d001c001a0603050304' + '030806080b0805080a080408090601050104010301000a0010000e11ed11eb0019001800170015010000160000003600' + '0504636c6934003300470045001700410459c382ae9a025039a4cf18e456797d1d40e8a139fad8b8599439822a4db40a' + '3ac424b5e62ff460976dd294f29cd293758ec4646c51feec0060cd67adcdc72ae9ff0100010000290040001b0015436c' + '69656e745f6964656e746974795348413235360000000000212011bb15a7723c35c0b15fadd5312bebdb97b5fe8c1673' + '48c7aeba64a2169572c2'), + ('S', '16fefd00000000000000000083020000770000000000000077fefdcf21ad74e59a6111be1d8c021e65b891c2a211167a' + 'bb8c5e079e09e2c8a8339c00130100004f002b0002fefc002c0045004320232eac3859d7c823503c79ebce1964a45481' + '2e11685b840a928470e6dc8d8cd513016d064fb17b5dd932eec197ecad805d9c81f436d2bc32e6d07799381026dc1759'), + ('C', '16fefd000000000000000101660100015a000100000000015afefd6a51df028ea6014c22cc84ed52a3339c8169dc1813' + 'c0c5c06300aa31e5944a7e00000004130100aa0100012c002b000302fefc002c0045004320232eac3859d7c823503c79' + 'ebce1964a454812e11685b840a928470e6dc8d8cd513016d064fb17b5dd932eec197ecad805d9c81f436d2bc32e6d077' + '99381026dc1759002d0003020001000d001c001a0603050304030806080b0805080a080408090601050104010301000a' + '0010000e11ed11eb00190018001700150100001600000036000504636c6934003300470045001700410459c382ae9a02' + '5039a4cf18e456797d1d40e8a139fad8b8599439822a4db40a3ac424b5e62ff460976dd294f29cd293758ec4646c51fe' + 'ec0060cd67adcdc72ae9ff0100010000290040001b0015436c69656e745f6964656e7469747953484132353600000000' + '002120ceeaf8ed350862aeb11af78a69ff83ea5091b1524e276d04406740388dc6b4d6'), + ('S', '16fefd000000000000000100960200008a000100000000008afefd898d015731b30a6e72204d04801134454e3adce2d3' + '6324877f41b1dbaa9bb790001301000062002900020000003300450017004104d5fa378935339c7196321c142be55caa' + '4f808dd09c31b09b4a928756dd435b0fd4ce403f074fe4b6f2873b07362dd20b0ebf878aff0316d939a01c3ba6a12cbe' + '002b0002fefc00360009085352562d43494438'), + ('S', '3e636c69347f14001fd76e1e029b930d1e87922af501b31010d0874c8fb7e777432de932d82c2025'), + ('S', '3e636c6934d8d5003dcb238bc73e714f266090997560c60b954f5f42c4506f39ce6b242cb72847952538a5336fcded73' + '75abf27a7880b65f4e64dee27f36e390b52d8e1dd8a7'), + ('C', '3e5352562d434944380f80003da4173a4beefc8f85af6d655126b0acdcca02bb9c17776eb8dbd2fded2aeb1430857bf0' + 'cc219b1a3ad804d90437ba5a768346c2d6d8a3f020ce0df15926'), + ('S', '3f636c6934bd470023375a37380f3573e4a7ff2565eab3f49d5e687406d6559c48238a22afb7fb8959eb2445'), + ('C', '3f5352562d4349443893ed001fa4c15c16ababd1a42babb2830e263463392dbea296593f9fff16c087a70997'), + ('S', '3f636c6934046a002732fa988e0dfa32e8e8cc38edb60b11b4cf80223d245742017c7d7606b19b3e19bd25874f3b8bcb'), + ('S', '3f636c6934a1e700136f937d6540c19eb544fe26758492bb3da27238'), + ('C', '3f5352562d43494438ffe60013317a8d1aa3deec6712ba5f8542f142362afbf6'), +)) + +#: Index of the first record with a connection ID in :data:`WOLFSSL`. +FIRST_CID_RECORD = 4 + +#: Per datagram: its protochain, and ``(cid, epoch, sequence number, length)`` +#: of each unified-header record with a connection ID in it, as tshark reports +#: them: ``dtls.record.connection_id``, ``dtls.unified_header.epoch_bits``, +#: ``dtls.record.sequence_suffix`` and ``dtls.record.length``. +TSHARK = ( + ('UDP:DTLS 1.3', ()), + ('UDP:DTLS 1.3', ()), + ('UDP:DTLS 1.3', ()), + ('UDP:DTLS 1.3', ()), + ('UDP:DTLS 1.3', ((CLIENT_CID, 2, 32532, 31),)), + ('UDP:DTLS 1.3', ((CLIENT_CID, 2, 55509, 61),)), + ('UDP:DTLS 1.3', ((SERVER_CID, 2, 3968, 61),)), + ('UDP:DTLS 1.3', ((CLIENT_CID, 3, 48455, 35),)), + ('UDP:DTLS 1.3', ((SERVER_CID, 3, 37869, 31),)), + ('UDP:DTLS 1.3', ((CLIENT_CID, 3, 1130, 39),)), + ('UDP:DTLS 1.3', ((CLIENT_CID, 3, 41447, 19),)), + ('UDP:DTLS 1.3', ((SERVER_CID, 3, 65510, 19),)), +) + +#: A 16-octet stand-in for an AEAD ciphertext, the shortest the record-number +#: sample allows (RFC 9147 section 4.2.3). +FRAGMENT = bytes(range(0xA0, 0xB0)) + + +def _unified(cid: bytes, fragment: bytes = FRAGMENT, *, sequence: bytes = b'\x07', + length_present: bool = True, epoch: int = 1) -> bytes: + """A unified-header record with a connection ID, as RFC 9147 section 4's Figure 3 lays it out.""" + first = 0b00110000 | (len(sequence) == 2) << 3 | length_present << 2 | epoch + length = len(fragment).to_bytes(2, 'big') if length_present else b'' + return bytes([first]) + cid + sequence + length + fragment + + +def _cid_layers(proto: 'Any') -> 'tuple': + """``(cid, epoch, sequence number, length)`` of each unified-header layer with a connection ID under ``proto``.""" + from pcapkit.protocols.data.application.dtls import DTLSCiphertext + + return tuple((layer.info.cid, layer.info.epoch, layer.info.sequence_number, layer.info.length) + for layer in _dtls_layers(proto)[0] + if isinstance(layer.info, DTLSCiphertext) and layer.info.cid is not None) + + +class CaptureTests(unittest.TestCase): + """The wolfSSL exchange reads as tshark reads it.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_the_handshake_teaches_each_direction_its_id(self) -> None: + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.application.dtls import DTLSContext + from pcapkit.protocols.transport.udp import UDP + + registry = ContextRegistry() + for index, ((from_client, payload), (chain, records)) in enumerate(zip(WOLFSSL, TSHARK), 1): + with self.subTest(datagram=index): + datagram = _udp(payload, from_client) + proto = UDP(datagram, len(datagram), __context__=registry) + self.assertEqual(str(proto.protochain), chain) + self.assertEqual(_cid_layers(proto.payload), records) + context = registry.match(['DTLS'], DTLSContext) + self.assertEqual(context.connection_ids, (CLIENT_CID, SERVER_CID)) # type: ignore[union-attr] + + def test_each_record_reports_its_header(self) -> None: + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + + wire = WOLFSSL[6][1] # first octet 0x3e: C, S and L set, epoch bits 2 + parsed = DTLS(wire, len(wire), __context__=DTLSContext(CLIENT_CID, SERVER_CID)) + self.assertEqual(parsed.alias, 'DTLS 1.3') + self.assertEqual(parsed.length, 1 + len(SERVER_CID) + 2 + 2) + self.assertEqual((parsed.info.cid, parsed.info.epoch, parsed.info.sequence_number, + parsed.info.sequence_long, parsed.info.length), + (SERVER_CID, 2, 3968, True, 61)) + self.assertEqual(parsed.info.content, wire[-61:]) + + def test_an_extraction_carries_the_ids_across_its_frames(self) -> None: + import pcapkit + + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + extraction = pcapkit.extract(fin=_ethernet_pcap(WOLFSSL), nofile=True) + try: + self.assertEqual([str(frame.protochain) for frame in extraction.frame], + [f'Ethernet:IPv4:{chain}' for chain, _ in TSHARK]) + self.assertEqual([_cid_layers(frame['UDP'].payload) for frame in extraction.frame], + [records for _, records in TSHARK]) + finally: + close_extractor(extraction) + + def test_a_caller_supplied_length_reads_records_without_the_handshake(self) -> None: + from pcapkit.protocols.application.dtls import DTLSContext + from pcapkit.protocols.transport.udp import UDP + + for index in range(FIRST_CID_RECORD, len(WOLFSSL)): + (from_client, payload), (chain, records) = WOLFSSL[index], TSHARK[index] + with self.subTest(datagram=index + 1): + context = DTLSContext(cid_length=len(records[0][0])) + datagram = _udp(payload, from_client) + proto = UDP(datagram, len(datagram), __context__=context) + self.assertEqual(str(proto.protochain), chain) + self.assertEqual(_cid_layers(proto.payload), records) + + def test_one_cid_length_does_not_read_both_directions_when_their_lengths_differ(self) -> None: + """``cid_length`` is a single value (#2515), so the caller passes the IDs instead.""" + from pcapkit.protocols.application.dtls import DTLSContext + from pcapkit.protocols.transport.udp import UDP + + for index in range(FIRST_CID_RECORD, len(WOLFSSL)): + (from_client, payload), (_, records) = WOLFSSL[index], TSHARK[index] + with self.subTest(datagram=index + 1): + datagram = _udp(payload, from_client) + by_length = UDP(datagram, len(datagram), __context__=DTLSContext(cid_length=len(CLIENT_CID))) + by_ids = UDP(datagram, len(datagram), __context__=DTLSContext(CLIENT_CID, SERVER_CID)) + self.assertEqual(str(by_length.protochain), 'UDP:Raw' if from_client else 'UDP:DTLS 1.3') + self.assertEqual(str(by_ids.protochain), 'UDP:DTLS 1.3') + self.assertEqual(_cid_layers(by_ids.payload), records) + + def test_an_id_the_capture_cannot_teach_comes_from_the_caller(self) -> None: + """A ``NewConnectionId`` is encrypted (RFC 9147 section 9), so the ID it issues is the caller's to give.""" + import pcapkit + from pcapkit.protocols.application.dtls import DTLSContext + + issued = b'issued-by-new-cid' + from_client, payload = WOLFSSL[-2] + moved = payload.replace(CLIENT_CID, issued, 1) + datagrams = WOLFSSL[:-2] + ((from_client, moved),) + for name, kwargs, chain in (('the handshake alone', {}, 'Ethernet:IPv4:UDP:Raw'), + ('the caller', {'context': DTLSContext(issued)}, 'Ethernet:IPv4:UDP:DTLS 1.3')): + with self.subTest(ids=name): + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + extraction = pcapkit.extract(fin=_ethernet_pcap(datagrams), nofile=True, **kwargs) + try: + self.assertEqual(str(extraction.frame[-1].protochain), chain) + if kwargs: + self.assertEqual(_cid_layers(extraction.frame[-1]['UDP'].payload), + ((issued, 3, 41447, 19),)) + self.assertEqual(kwargs['context'].connection_ids, (issued, CLIENT_CID, SERVER_CID)) + finally: + close_extractor(extraction) + + +class InstanceMemoryTests(unittest.TestCase): + """Learned connection IDs belong to one ``DTLSContext`` instance, as #2515 ruled.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def _extract(self, datagrams: 'tuple', **kwargs: 'Any') -> 'Any': + import pcapkit + + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + extraction = pcapkit.extract(fin=_ethernet_pcap(datagrams), nofile=True, **kwargs) + self.addCleanup(close_extractor, extraction) + return extraction + + def test_a_new_instance_carries_no_history(self) -> None: + from pcapkit.protocols.application.dtls import DTLSContext + + first = self._extract(WOLFSSL) + self.assertEqual(str(first.frame[-1].protochain), 'Ethernet:IPv4:UDP:DTLS 1.3') + for name, kwargs in (('no context', {}), ('a fresh instance', {'context': DTLSContext()})): + with self.subTest(second=name): + second = self._extract(WOLFSSL[FIRST_CID_RECORD:], **kwargs) + self.assertEqual({str(frame.protochain) for frame in second.frame}, {'Ethernet:IPv4:UDP:Raw'}) + + def test_a_reused_caller_instance_keeps_its_ids(self) -> None: + from pcapkit.protocols.application.dtls import DTLSContext + + context = DTLSContext() + self._extract(WOLFSSL, context=context) + self.assertEqual(context.connection_ids, (CLIENT_CID, SERVER_CID)) + + again = self._extract(WOLFSSL[FIRST_CID_RECORD:], context=context) + self.assertEqual([_cid_layers(frame['UDP'].payload) for frame in again.frame], + [records for _, records in TSHARK[FIRST_CID_RECORD:]]) + + +class QuietRefusalTests(unittest.TestCase): + """A record whose connection ID is unknown is ``Raw``, quietly, on dispatch (#2588).""" + + def setUp(self) -> None: + reimport_once_per_class(self) + # NOTE: Imported before the sentinels go in, since importing pcapkit + # outside development mode installs tbtrim's hook itself. + import pcapkit # pylint: disable=import-outside-toplevel,unused-import + saved_sys, saved_threading = sys.excepthook, threading.excepthook + + def _restore() -> None: + sys.excepthook = saved_sys + threading.excepthook = saved_threading + + self.addCleanup(_restore) + + def _sys_sentinel(*args: 'Any') -> None: + saved_sys(*args) + + def _threading_sentinel(args: 'Any') -> None: + saved_threading(args) + + self.sys_sentinel, self.threading_sentinel = _sys_sentinel, _threading_sentinel + sys.excepthook, threading.excepthook = _sys_sentinel, _threading_sentinel + + def test_dispatch_keeps_the_record_raw_and_logs_nothing(self) -> None: + from pcapkit.protocols.misc.raw import Raw + from pcapkit.protocols.transport.udp import UDP + from pcapkit.utilities import exceptions + from pcapkit.utilities.logging import logger + + for index in range(FIRST_CID_RECORD, len(WOLFSSL)): + from_client, payload = WOLFSSL[index] + with self.subTest(datagram=index + 1): + datagram = _udp(payload, from_client) + with capture(logger) as package, capture(exceptions.logger) as module: + proto = UDP(datagram, len(datagram)) + self.assertEqual(str(proto.protochain), 'UDP:Raw') + self.assertIs(type(proto.payload), Raw) + self.assertRegex(str(proto.payload.info.error), + r'^DTLS 1\.3: invalid format: no connection ID length is known ' + r'for a unified-header record') + self.assertEqual(package.messages + module.messages, []) + self.assertIs(sys.excepthook, self.sys_sentinel) + self.assertIs(threading.excepthook, self.threading_sentinel) + self.assertEqual(bytes(UDP.from_data(proto.info)).hex(), datagram.hex()) + + def test_a_direct_parse_raises(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.utilities.exceptions import ProtocolError + + wire = WOLFSSL[FIRST_CID_RECORD][1] + with self.assertRaisesRegex(ProtocolError, 'no connection ID length is known for a unified-header record'): + DTLS(wire, len(wire)) + + +class LayoutTests(unittest.TestCase): + """Each flag combination with ``C`` set, built as Figure 3 lays it out.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_each_flag_combination_parses(self) -> None: + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + + cid = b'\xc1\xc2\xc3' + for sequence in (b'\x07', b'\x12\x34'): + for length_present in (True, False): + with self.subTest(sequence_long=len(sequence) == 2, length_present=length_present): + wire = _unified(cid, sequence=sequence, length_present=length_present, epoch=3) + parsed = DTLS(wire, len(wire), __context__=DTLSContext(cid)) + self.assertEqual(parsed.alias, 'DTLS 1.3') + self.assertEqual(parsed.length, 1 + len(cid) + len(sequence) + 2 * length_present) + self.assertEqual((parsed.info.cid, parsed.info.epoch, parsed.info.sequence_number, + parsed.info.sequence_long, parsed.info.length, parsed.info.content), + (cid, 3, int.from_bytes(sequence, 'big'), len(sequence) == 2, + len(FRAGMENT) if length_present else None, FRAGMENT)) + self.assertEqual(bytes(DTLS.from_data(parsed.info)).hex(), wire.hex()) + + def test_a_record_without_a_cid_reports_none(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + + wire = b'\x25\x07' + len(FRAGMENT).to_bytes(2, 'big') + FRAGMENT + parsed = DTLS(wire, len(wire)) + self.assertIsNone(parsed.info.cid) + self.assertEqual(parsed.length, 4) + + def test_a_header_cut_short_is_refused(self) -> None: + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + from pcapkit.utilities.exceptions import ProtocolError + + for name, wire in (('no connection ID octets', b'\x3c'), + ('half a connection ID', b'\x3c\xc1\xc2'), + ('no sequence number', b'\x3c\xc1\xc2\xc3\xc4')): + with self.subTest(case=name), warnings.catch_warnings(), self.assertRaises(ProtocolError): + warnings.simplefilter('ignore') # the schema's short read + DTLS(wire, len(wire), __context__=DTLSContext(cid_length=4)) + + +class ResolutionTests(unittest.TestCase): + """``DTLSContext.match`` finds a unified header's ID by the same rules as a ``tls12_cid`` one's.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_a_learned_id_comes_before_cid_length(self) -> None: + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + + for name, wire, context, header in ( + ('the capture', WOLFSSL[6][1], DTLSContext(SERVER_CID, cid_length=len(CLIENT_CID)), + (SERVER_CID, 3968, 61)), + # Either length leaves a record that fits, so only the order decides. + ('either fits', _unified(b'\xab\xcd', bytes(32), length_present=False), + DTLSContext(b'\xab\xcd', cid_length=1), (b'\xab\xcd', 7, None)), + ): + with self.subTest(case=name): + parsed = DTLS(wire, len(wire), __context__=context) + self.assertEqual((parsed.info.cid, parsed.info.sequence_number, parsed.info.length), header) + + def test_a_shorter_id_is_not_read_as_a_longer_one(self) -> None: + """``ab``'s sequence number opens with ``cd``, so ``abcd`` fits too unless the sample must.""" + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + + for name, fragment, length_present in ( + ('without a length', FRAGMENT, False), + # Past ``abcd`` the length reads 5: the record fits, with no room for the sample. + ('with a length', b'\x05' + bytes(255), True), + ): + with self.subTest(case=name): + wire = _unified(b'\xab', fragment, sequence=b'\xcd\x00', length_present=length_present) + parsed = DTLS(wire, len(wire), __context__=DTLSContext(b'\xab', b'\xab\xcd')) + self.assertEqual((parsed.info.cid, parsed.info.sequence_number, parsed.info.content), + (b'\xab', 0xCD00, fragment)) + + def test_without_a_length_the_sample_bounds_the_fit(self) -> None: + """The sample's octets after ``abcd``'s header fit; with one fewer, only ``ab``'s shorter header leaves room.""" + from pcapkit.protocols.application.dtls import DTLSContext + + context = DTLSContext(b'\xab', b'\xab\xcd') + for rest, expected in ((16, 2), (15, 1), (14, 2)): # neither fits at 14: the first candidate + with self.subTest(octets_after_the_sequence_number=rest): + octets = b'\xab\xcd\x07' + bytes(rest) + self.assertEqual(context.match(octets, sequence_length=1, length_present=False, + min_content_length=16), expected) + for size, expected in ((3, 2), (2, 1)): # no minimum, as for tls12_cid: the header alone fits + with self.subTest(octets=size): + self.assertEqual(context.match(b'\xab\xcd\x07'[:size], sequence_length=1, length_present=False), + expected) + + def test_the_longest_id_whose_record_fits(self) -> None: + """The fit is read past the sequence number, which a ``tls12_cid`` record does not have.""" + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + + fragment = b'\xff' + bytes(15) + for name, cid, ids in ( + ('both ids open it, the longer fits', b'\xab\xcd', (b'\xab', b'\xab\xcd')), + # Past ``abcd`` and a sequence octet the length reads 0x10ff, which does not fit; + # with no sequence octet, as in a tls12_cid record, it would read 0x0010 and fit. + ('the longer opens it but does not fit', b'\xab', (b'\xab', b'\xab\xcd')), + ): + with self.subTest(case=name): + wire = _unified(cid, fragment, sequence=b'\xcd') + parsed = DTLS(wire, len(wire), __context__=DTLSContext(*ids)) + self.assertEqual((parsed.info.cid, parsed.info.content), (cid, fragment)) + self.assertEqual(parsed.length, 1 + len(cid) + 1 + 2) + + def test_without_a_length_the_longest_id_whose_header_fits(self) -> None: + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + + wire = _unified(b'\xab\xcd', length_present=False) + parsed = DTLS(wire, len(wire), __context__=DTLSContext(b'\xab', b'\xab\xcd')) + self.assertEqual((parsed.info.cid, parsed.info.length, parsed.info.content), (b'\xab\xcd', None, FRAGMENT)) + + def test_match_takes_the_unified_layout(self) -> None: + from pcapkit.protocols.application.dtls import DTLSContext + + context = DTLSContext(b'\xab', b'\xab\xcd') + octets = _unified(b'\xab', b'\xff' + bytes(15), sequence=b'\xcd')[1:] + self.assertEqual(context.match(octets, sequence_length=1), 1) + self.assertEqual(context.match(octets), 2) # read as a tls12_cid record's ID, length 0x0010 + self.assertEqual(context.match(octets, sequence_length=1, length_present=False), 2) + self.assertIsNone(DTLSContext().match(octets, sequence_length=1)) + + +class ChainTests(unittest.TestCase): + """A unified-header record with a connection ID chains with the records sharing its datagram (#2535).""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_records_either_side_of_one_are_further_layers(self) -> None: + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + + record = _unified(CLIENT_CID) + last = _unified(CLIENT_CID, FRAGMENT + CCS, length_present=False) + for name, wire, chain in (('after a legacy record', CCS + record, 'DTLS 1.2:DTLS 1.3'), + ('before a legacy record', record + CCS, 'DTLS 1.3:DTLS 1.2'), + ('two of them', record + record, 'DTLS 1.3:DTLS 1.3'), + ('one without a length, last', CCS + record + last, 'DTLS 1.2:DTLS 1.3:DTLS 1.3')): + with self.subTest(case=name): + parsed = DTLS(wire, len(wire), __context__=DTLSContext(CLIENT_CID)) + self.assertEqual(str(parsed.protochain), chain) + self.assertEqual(bytes(DTLS.from_data(parsed.info)).hex(), wire.hex()) + parsed = DTLS(CCS + last, len(CCS + last), __context__=DTLSContext(CLIENT_CID)) + self.assertEqual(_cid_layers(parsed), ((CLIENT_CID, 1, 7, None),)) + self.assertEqual(parsed.payload.info.content, FRAGMENT + CCS) + + def test_an_unknown_id_after_a_record_stays_raw_without_an_error(self) -> None: + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + from pcapkit.protocols.misc.raw import Raw + + record = _unified(CLIENT_CID) + for name, tail, kwargs in (('no context', record, {}), + ('another id', record, {'__context__': DTLSContext(SERVER_CID)}), + ('a header cut short', record[:5], {'__context__': DTLSContext(CLIENT_CID)})): + with self.subTest(case=name): + parsed = DTLS(CCS + tail, len(CCS + tail), **kwargs) + self.assertEqual(str(parsed.protochain), 'DTLS 1.2:Raw') + self.assertIs(type(parsed.payload), Raw) + self.assertEqual(bytes(parsed.payload), tail) + self.assertIsNone(parsed.payload.info.error) + + +class RoundTripTests(unittest.TestCase): + """Byte-exact both ways: parse then rebuild, and make then parse.""" + + def setUp(self) -> None: + reimport_once_per_class(self) + + def test_parse_then_rebuild_is_byte_exact(self) -> None: + from pcapkit.corekit.context import ContextRegistry + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.protocols.transport.udp import UDP + + registry = ContextRegistry() + for index, (from_client, payload) in enumerate(WOLFSSL, 1): + with self.subTest(datagram=index): + datagram = _udp(payload, from_client) + udp = UDP(datagram, len(datagram), __context__=registry) + rebuilt = DTLS.from_data(udp.payload.info) + self.assertEqual(bytes(rebuilt).hex(), payload.hex()) + self.assertEqual(str(rebuilt.protochain), str(udp.payload.protochain)) + + again = UDP.from_data(udp.info, __context__=registry) + self.assertEqual(bytes(again).hex(), datagram.hex()) + self.assertEqual(str(again.protochain), str(udp.protochain)) + with warnings.catch_warnings(): + warnings.simplefilter('ignore') + self.assertEqual(bytes(UDP.from_data(udp.info)).hex(), datagram.hex()) + + def test_make_then_parse_is_byte_exact(self) -> None: + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + + maker = object.__new__(DTLS) + for index in range(FIRST_CID_RECORD, len(WOLFSSL)): + wire = WOLFSSL[index][1] + with self.subTest(datagram=index + 1): + parsed = DTLS(wire, len(wire), __context__=DTLSContext(CLIENT_CID, SERVER_CID)) + info = parsed.info + made = bytes(maker.make(ciphertext=True, epoch=info.epoch, cid=info.cid, + sequence_number=info.sequence_number, sequence_long=info.sequence_long, + length_present=info.length is not None, content=info.content)) + self.assertEqual(made.hex(), wire.hex()) + + again = DTLS(made, len(made), __context__=DTLSContext(CLIENT_CID, SERVER_CID)) + self.assertEqual(_cid_layers(again), _cid_layers(parsed)) + + made = DTLS(ciphertext=True, epoch=2, sequence_number=0x1234, sequence_long=True, cid=b'\x0a\x0b', + content=FRAGMENT) + self.assertEqual(bytes(made).hex(), _unified(b'\x0a\x0b', sequence=b'\x12\x34', epoch=2).hex()) + self.assertEqual((made.alias, made.length, made.info.cid, made.info.length), ('DTLS 1.3', 7, b'\x0a\x0b', 16)) + + def test_a_record_made_with_a_payload_parses_it_again(self) -> None: + """The constructed payload's connection IDs are registered for its parse, on the instance in use.""" + from pcapkit.const.tls.content_type import ContentType + from pcapkit.protocols.application.dtls import DTLS, DTLSContext + + def build(**kwargs: 'Any') -> 'Any': + inner = DTLS(ciphertext=True, epoch=3, sequence_number=9, cid=SERVER_CID, content=FRAGMENT) + return DTLS(content_type=ContentType.change_cipher_spec, epoch=0, sequence_number=3, + content=b'\x01', payload=inner, **kwargs) + + context = DTLSContext() + outer = build(__context__=context) + self.assertEqual(str(outer.protochain), 'DTLS 1.2:DTLS 1.3') + self.assertEqual(_cid_layers(outer), ((SERVER_CID, 3, 9, 16),)) + self.assertEqual(context.connection_ids, (SERVER_CID,)) + self.assertEqual(build().context.match(['DTLS'], DTLSContext).connection_ids, (SERVER_CID,)) + self.assertEqual(DTLSContext().connection_ids, ()) + + def test_make_refuses_an_invalid_id(self) -> None: + from pcapkit.protocols.application.dtls import DTLS + from pcapkit.utilities.exceptions import ProtocolError + + maker = object.__new__(DTLS) + for name, cid in (('an empty one', b''), ('a 256-octet one', bytes(256)), ('a str', 'ab')): + with self.subTest(case=name), self.assertRaisesRegex(ProtocolError, 'invalid connection ID'): + maker.make(ciphertext=True, content=FRAGMENT, cid=cid) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/protocols/application/test_dtls_connection_id_2515_unit.py b/tests/protocols/application/test_dtls_connection_id_2515_unit.py index 3b1a3c1060..bd43829ec7 100644 --- a/tests/protocols/application/test_dtls_connection_id_2515_unit.py +++ b/tests/protocols/application/test_dtls_connection_id_2515_unit.py @@ -647,8 +647,9 @@ def test_make_refuses_a_misplaced_or_invalid_id(self) -> None: ('a 256-octet one', {'content_type': ContentType.tls12_cid, 'cid': bytes(256)}), ('one on application_data', {'content_type': ContentType.application_data, 'cid': b'\x01'}), - ('one on a unified header', {'ciphertext': True, 'content': bytes(16), - 'cid': b'\x01'})): + # A unified header takes a connection ID (#2625), but not an empty one. + ('an empty one on a unified header', {'ciphertext': True, 'content': bytes(16), + 'cid': b''})): with self.subTest(case=name), self.assertRaisesRegex(ProtocolError, 'invalid connection ID'): maker.make(**kwargs)