From aed148d816f9bcf13ec41cd937bb1a348cecd964 Mon Sep 17 00:00:00 2001 From: Brian Sipos Date: Fri, 7 Aug 2026 00:37:16 -0400 Subject: [PATCH] Ported TCPCLv4 message definitions with scapy-friendly patterns --- scapy/contrib/tcpcl.py | 432 +++++++++++++++++++ test/contrib/tcpcl.uts | 77 ++++ test/pcaps/dtn_tcpclv4_bpv7_nocontact.pcapng | Bin 0 -> 2772 bytes test/pcaps/dtn_tcpclv4_bpv7_transfer.pcapng | Bin 0 -> 3960 bytes 4 files changed, 509 insertions(+) create mode 100644 scapy/contrib/tcpcl.py create mode 100644 test/contrib/tcpcl.uts create mode 100644 test/pcaps/dtn_tcpclv4_bpv7_nocontact.pcapng create mode 100644 test/pcaps/dtn_tcpclv4_bpv7_transfer.pcapng diff --git a/scapy/contrib/tcpcl.py b/scapy/contrib/tcpcl.py new file mode 100644 index 00000000000..c858668ffe3 --- /dev/null +++ b/scapy/contrib/tcpcl.py @@ -0,0 +1,432 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) 2016-2026 Brian Sipos + +# scapy.contrib.description = DTN TCP Convergence Layer +# scapy.contrib.status = loads + +import enum +import struct +from typing import ClassVar +from scapy import volatile, packet +from scapy.config import conf +from scapy.error import log_runtime +from scapy.layers.inet import TCP +from scapy.packet import Packet, bind_layers +from scapy.fields import ( + ConditionalField, + ByteField, + ByteEnumField, + XByteField, + ShortField, + XShortField, + LongField, + FieldLenField, + StrFixedLenField, + LenField, + StrLenField, + FlagsField, + PacketListField, +) +from scapy.contrib.sdnv import SDNV2FieldLenField + +MAGIC_HEAD = b"dtn!" +"""Header magic prefix data.""" + + +class TCPCL(Packet): + """ + This is a pseudo-packet class to dispatch to the real messages + from a TCP stream. + """ + + name = "TCPCL" + + def extract_padding(self, s): + """No payload, all extra data is padding""" + return (None, s) + + @classmethod + def tcp_reassemble(cls, data, metadata, session): + tcp = None + if "original" in metadata: + tcp = metadata.get("original")[TCP] + + pkt = None + if "tcpcl-version-i" not in session or "tcpcl-version-r" not in session: + pkt = _BaseContact(data) + if isinstance(pkt, _BaseContact): + log_runtime.info("TCPCL version %s", pkt.version) + if "tcpcl-version-i" not in session: + # initiator version + if tcp: + session["tcpcl-port-i"] = tcp.sport + session["tcpcl-port-r"] = tcp.dport + session["tcpcl-version-i"] = int(pkt.version) + elif "tcpcl-version-r" not in session: + # responder version + if tcp.sport != session["tcpcl-port-r"]: + raise RuntimeError("bad responder SESS_INIT") + session["tcpcl-version-r"] = int(pkt.version) + else: + # something other than contact + pkt = _BaseMsgV4(data) + if isinstance(pkt, _BaseMsgV4): + log_runtime.warning('TCPCL session without a contact header, assuming v4') + session["tcpcl-version-i"] = session["tcpcl-version-r"] = 4 + else: + log_runtime.error('TCPCL session without a contact header or message') + else: + vers = session["tcpcl-version-r"] + if vers == 4: + pkt = _BaseMsgV4(data) + + return pkt + + +bind_layers(TCP, TCPCL, dport=4556) +bind_layers(TCP, TCPCL, sport=4556) + + +class _BaseContact(TCPCL): + """ + Initial stream content, separate from later messaging. + """ + + fields_desc = [ + StrFixedLenField("magic", default=MAGIC_HEAD, length=4), + ByteField("version", default=None), + ] + + _reg_variants: ClassVar[dict[int, "_BaseContact"]] = {} + """Known contact versions.""" + + @classmethod + def register_variant(cls): + """ + Registers the version-specific header. + """ + if cls.version.default is not None: + cls._reg_variants[cls.version.default] = cls + + @classmethod + def dispatch_hook(cls, _pkt=None, *args, **kargs): + """ + Returns the right sub-class for the given data. + """ + if _pkt and len(_pkt) >= 5: + magic = _pkt[:4] + if magic == MAGIC_HEAD: + vers = struct.unpack("!B", _pkt[4:5])[0] + return cls._reg_variants.get(vers, cls) + return conf.raw_layer + + +class ContactV3(_BaseContact): + """ + Version 3 contact header from RFC 7242. + """ + + name = "TCPCLv3 Contact" + + @enum.unique + class Flag(enum.IntEnum): + ENA_ACK = 0x01 + ENA_FRAG = 0x02 + ENA_REFUSE = 0x04 + ENA_LENGTH = 0x08 + + fields_desc = [ + StrFixedLenField("magic", default=MAGIC_HEAD, length=4), + ByteField("version", default=3), + FlagsField( + "flags", default=0, size=8, names={item.value: item.name for item in Flag} + ), + ShortField("keepalive", default=0), + SDNV2FieldLenField("nodeid_length", default=None, length_of="nodeid_data"), + StrLenField( + "nodeid_data", default=b"", length_from=lambda pkt: pkt.nodeid_length + ), + ] + + +class ContactV4(_BaseContact): + """ + Version 4 contact header from RFC 9174. + """ + + name = "TCPCLv4 Contact" + + @enum.unique + class Flag(enum.IntEnum): + CAN_TLS = 0x01 + + fields_desc = [ + StrFixedLenField("magic", default=MAGIC_HEAD, length=4), + ByteField("version", default=4), + FlagsField( + "flags", default=0, size=8, names={item.value: item.name for item in Flag} + ), + ] + + +class _BaseMsgV4(TCPCL): + """ + Base class for all TCPCL message types. + """ + + fields_desc = [ + XByteField("msg_type", default=None), + ] + + _reg_variants: ClassVar[dict[int, "_BaseMsgV4"]] = {} + """ Known message types """ + + @classmethod + def register_variant(cls): + """ + Registers the version-specific header. + """ + if cls.msg_type.default is not None: + cls._reg_variants[cls.msg_type.default] = cls + + @classmethod + def dispatch_hook(cls, _pkt=None, *args, **kargs): + """ + Returns the right sub-class for the given data. + """ + if _pkt and len(_pkt) >= 1: + msg_type = struct.unpack("!B", _pkt[:1])[0] + return cls._reg_variants.get(msg_type, cls) + return conf.raw_layer + + +TCPCL_MRU_SIZE_MAX = 2**64 - 1 +"""Largest 64-bit size value.""" + + +class ExtensionListField(PacketListField): + """Provide useful randval() that fixes scapy behavior.""" + + def randval(self): + count = volatile.RandNum(0, 4) + reprobj = self.cls() + items = [] + for _ in range(count): + items.append(packet.fuzz(reprobj)) + return items + + +class _TlvHead(Packet): + """ + Generic TLV header with data as payload. + """ + + @enum.unique + class Flag(enum.IntEnum): + """ + Extension item flags. + """ + + CRITICAL = 0x01 + + fields_desc = [ + FlagsField( + "flags", default=0, size=8, names={item.value: item.name for item in Flag} + ), + XShortField("type", default=None), + LenField("length", default=None, fmt="H"), + ] + + +class SessExt(_TlvHead): + """ + Session extension header to bind layers to. + """ + + name = "TCPCL SESS_EXT" + + +class SessionInit(_BaseMsgV4): + """ + A SESS_INIT message. + """ + + name = "TCPCL SESS_INIT" + + fields_desc = [ + XByteField("msg_type", default=0x07), + ShortField("keepalive", default=0), + LongField("segment_mru", default=TCPCL_MRU_SIZE_MAX), + LongField("transfer_mru", default=TCPCL_MRU_SIZE_MAX), + FieldLenField("nodeid_length", default=None, fmt="H", length_of="nodeid_data"), + StrLenField( + "nodeid_data", default="", length_from=lambda pkt: pkt.nodeid_length + ), + FieldLenField("ext_size", default=None, fmt="I", length_of="ext_items"), + ExtensionListField( + "ext_items", + default=[], + pkt_cls=SessExt, + length_from=lambda pkt: pkt.ext_size, + ), + ] + + +class SessionTerm(_BaseMsgV4): + """ + A SESS_TERM message. + """ + + name = "TCPCL SESS_TERM" + + @enum.unique + class Flag(enum.IntEnum): + """Message flags. + Flags must be in LSbit-first order. + """ + + REPLY = 0x01 + + @enum.unique + class Reason(enum.IntEnum): + """Reason code points.""" + + UNKNOWN = 0 + IDLE_TIMEOUT = 1 + VERSION_MISMATCH = 2 + BUSY = 3 + CONTACT_FAILURE = 4 + RESOURCE_EXHAUSTION = 5 + + fields_desc = [ + XByteField("msg_type", default=0x05), + FlagsField( + "flags", default=0, size=8, names={item.value: item.name for item in Flag} + ), + ByteEnumField( + "reason", + default=Reason.UNKNOWN, + enum={item.value: item.name for item in Reason}, + ), + ] + + +class XferExt(_TlvHead): + """ + Transfer extension header to bind layers to. + """ + + name = "TCPCL XFER_EXT" + + +class XferSegment(_BaseMsgV4): + """ + A XFER_SEGMENT message with transfer data as field (not payload). + """ + + name = "TCPCL XFER_SEGMENT" + + @enum.unique + class Flag(enum.IntEnum): + """ + Transfer flags. + """ + + END = 0x01 + START = 0x02 + + fields_desc = [ + XByteField("msg_type", default=0x01), + FlagsField( + "flags", default=0, size=8, names={item.value: item.name for item in Flag} + ), + LongField("transfer_id", default=None), + ConditionalField( + cond=lambda pkt: pkt.flags & XferSegment.Flag.START, + fld=FieldLenField("ext_size", default=None, fmt="I", length_of="ext_items"), + ), + ConditionalField( + cond=lambda pkt: pkt.flags & XferSegment.Flag.START, + fld=ExtensionListField( + "ext_items", + default=[], + pkt_cls=XferExt, + length_from=lambda pkt: pkt.ext_size, + ), + ), + FieldLenField("length", default=None, fmt="Q", length_of="data"), + StrLenField("data", default=b"", length_from=lambda pkt: pkt.length), + ] + + +class XferAck(_BaseMsgV4): + name = "TCPCL XFER_ACK" + + fields_desc = [ + XByteField("msg_type", default=0x02), + FlagsField( + "flags", + default=0, + size=8, + names={item.value: item.name for item in XferSegment.Flag}, + ), + LongField("transfer_id", default=None), + LongField("ack_length", default=None), + ] + + +class XferRefuse(_BaseMsgV4): + name = "TCPCL XFER_REFUSE" + + @enum.unique + class Reason(enum.IntEnum): + """Reason code points.""" + + UNKNOWN = 0x00 + COMPLETED = 0x01 + NO_RESOURCES = 0x02 + RETRANSMIT = 0x03 + NOT_ACCEPTABLE = 0x04 + EXT_FAILURE = 0x05 + + fields_desc = [ + XByteField("msg_type", default=0x03), + ByteEnumField( + "reason", + default=Reason.UNKNOWN, + enum={item.value: item.name for item in Reason}, + ), + LongField("transfer_id", default=None), + ] + + +class Keepalive(_BaseMsgV4): + name = "TCPCL KEEPALIVE" + + fields_desc = [ + XByteField("msg_type", default=0x04), + ] + + +class MsgReject(_BaseMsgV4): + name = "TCPCL MSG_REJECT" + + @enum.unique + class Reason(enum.IntEnum): + """Reason code points.""" + + UNKNOWN = 0x01 + UNSUPPORTED = 0x02 + UNEXPECTED = 0x03 + + fields_desc = [ + XByteField("msg_type", default=0x06), + ByteEnumField( + "reason", + default=Reason.UNKNOWN, + enum={item.value: item.name for item in Reason}, + ), + XByteField("rejected_type", default=None), + ] diff --git a/test/contrib/tcpcl.uts b/test/contrib/tcpcl.uts new file mode 100644 index 00000000000..bd383cc6b85 --- /dev/null +++ b/test/contrib/tcpcl.uts @@ -0,0 +1,77 @@ + ++ Direct message interface + += not enough data +from scapy.contrib import tcpcl +pkt = tcpcl._BaseContact(b'dtn!') +assert isinstance(pkt, conf.raw_layer) + + += TCPCLv4 contact +from scapy.contrib import tcpcl +pkt = tcpcl._BaseContact(bytes.fromhex('64746e2104010000')) +pkt.show() +assert isinstance(pkt, tcpcl.ContactV4) +assert isinstance(pkt.payload, Padding) + + + ++ TCP Session interface + += read from test file starting at contact +from scapy.contrib import tcpcl +filepath = scapy_path("test/pcaps/dtn_tcpclv4_bpv7_transfer.pcapng") +packets = sniff(offline=filepath, session=TCPSession) +assert len(packets) == 27 + +# notable packets +pkt = packets[3].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.ContactV4) +pkt = packets[5].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.ContactV4) +pkt = packets[7].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.SessionInit) +pkt = packets[9].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.SessionInit) + +pkt = packets[11].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.XferSegment) +assert pkt.transfer_id == 1 +assert pkt.flags == tcpcl.XferSegment.Flag.START +assert pkt.length == 100 +pkt = packets[13].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.XferSegment) +assert pkt.transfer_id == 1 +assert pkt.flags == tcpcl.XferSegment.Flag.END +assert pkt.length == 99 +pkt = packets[19].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.XferAck) +assert pkt.transfer_id == 1 + +pkt = packets[15].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.XferSegment) +assert pkt.transfer_id == 2 +assert pkt.flags == tcpcl.XferSegment.Flag.START +assert pkt.length == 100 +pkt = packets[17].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.XferSegment) +assert pkt.transfer_id == 2 +assert pkt.flags == tcpcl.XferSegment.Flag.END +assert pkt.length == 99 + +pkt = packets[23].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.SessionTerm) +pkt = packets[24].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.SessionTerm) + + += read from test file starting after contact +from scapy.contrib import tcpcl +filepath = scapy_path("test/pcaps/dtn_tcpclv4_bpv7_nocontact.pcapng") +packets = sniff(offline=filepath, session=TCPSession) +assert len(packets) == 16 + +# notable packets +pkt = packets[0].getlayer(tcpcl.TCPCL, _subclass=True) +assert isinstance(pkt, tcpcl.XferSegment) +assert pkt.transfer_id == 1 diff --git a/test/pcaps/dtn_tcpclv4_bpv7_nocontact.pcapng b/test/pcaps/dtn_tcpclv4_bpv7_nocontact.pcapng new file mode 100644 index 0000000000000000000000000000000000000000..80a572afc30ffa22e180b4382d6b0496fa575b46 GIT binary patch literal 2772 zcmd6pUu;@c9LLY)Pw7~Rh0>MFR(n_62J1jrVYK^`VgIZyaSzh9n~6#o;J~#|DPX}g zg}b6Ln=gAXF>dQppEl=~`eGMNeB6W8Cp9zvNtT$HkUp4YnrKYx?{~Q8LJ{w)p5&MN zyN7en_k2I+ceqz?Fz8naA*YV^Svk0FN=-$M5|1Yn7sg^q;bbxso3aNT!f+}bv!6cY z5E4G8$J6f{^9uptpv&tXd$u6hUr)gJ@$pfw>!3qTI>~d1WOh#Qxjb%{+v#z82c47JY%m&e zd&3TRnh;zsuJts;YY;f2il|9AnJ3%emu=9qo#+XfNu@J_*XKJ#NSnfXa0Z;K1!5g< zpdD6yv_t=B&in=B7=)-bbO6m;$tWS8R=NWu&=TxkfaPFZUpKBT%xCuPdF0j?Eh@5c z7dp9C{lgvSj1W(7D*AAB@dmuKN*(v5Yjo+7j_;e~M3u;&-{7C#i-~!O-}MtFrWIau zMzm_a34YJ3AMS)_+KwD(`;2c%^198TiJ!|lo#ifhU^$6DaqjGw{<2#KcMWQQj zxA2x_^58i2h+dN1x#LPO+Wa+^}!)YQ(uw z;|H7$8ftvqUNql>9HWWG&B~LMr;BMwJnzUnugMxFWXfR?U}M+FSzC$YC_M`8qR44Q z)lfVks&%E3$c<*=v2?7=acW?4)+=(0i{fJn>(U3m+Qi3J_rBY2Nj>lU`NwSOH#Km# zykxm2TE~yg9^*NF6XeiSBfrmkipu8UY5^uCu*n|H+G(*DweV9eR&KU3k$Fb>}{af4*%V?|1(@$C-Xl?y;l8Ko?kOh!aYV4|0hC=)PEn-kQfIR=7BN3 z9}dg@yHG=R%e=vB0reWaO_bM^z4cOWOH8-UTd=_!>UJ@2akK#q_jYBfXdQzbqlq_v z#YQ#WZ>oXb`ebi8)X>5#I6r!+*LkC3Sy3lcrVIy}j>Iqs40|cVnob^ca$2Yqrh~q) zp`Tw~T(*utj?u)AxAGMAbB*ap%zB|7CK{NbPM$HV*C^arQP$W)HLf<*Fm9>2!oO+E z-u^^UBlITab%be1ye5E`t@uxkLNgz`e^=J1?4>>$_a5IflMSrU2i3s+pQ7vu>HJDd zOz)Zu9N9&NQ(5@9OwS54=~ONejYWlXc_CW;I8?#+5sr;azt^Ugasxc4fxKIqODlgv IRXb4QZ(LX^$p8QV literal 0 HcmV?d00001 diff --git a/test/pcaps/dtn_tcpclv4_bpv7_transfer.pcapng b/test/pcaps/dtn_tcpclv4_bpv7_transfer.pcapng new file mode 100644 index 0000000000000000000000000000000000000000..4bdf152fddb913f478c88068411b5e284857354e GIT binary patch literal 3960 zcmd6qZA?>F7{|}OEfxz!UW&Apmn#e@Ahl?b7cruO%qbs+f-V{Bkaoh~U9l>b6!Lt*h&P z&f~B5^|(FV0!I0poso6?My3TrV z2ApdZ^y7E}t$}}c?ZnJaIs0LZoe+bO-ayBxq=k@1DX)n%t!kehgXLhq@*rz|Y&2xT zTc-H<-6c06yfJ>)$fug`uRW5A*Exg1gbX!nZb6?#2(lVKWUQ?DD-TK+H%Y)toyv~i zQL~Vj%<>@|8hX9H_UD{t7-QG)@<=x7b&auPuTy0oI%MO{?(8qMK(26eDHCP_+DP(=Shxy3aEkJe| zDj#H};oNGU^C#ONgZ z?`$u3<-?@N?Xp~+b3xCR;j*7555UAm4^KF#=T#+Uc8 ztaQ_-KUL3OCfphE{`}mPmKp6#^40ODy>|+)(IG#=f7vX5Z1O3>7 zliTtOzR5Etdu1ml@}m9dl6}(#99Spq`QPCn`H7$VtWd%>=ubBmS(ftrpdh>1Rd2iQ&%ypo2Pe- z?&mJ%ny4AK8EWQ6G?R-beQu#^7R&P+*$nIQ^USDq4$iS_~X-MlRCdoilX}YMQMIJ zncH%H;SyDT=$6g$ORmSS^3LklV-d}79b+W&3zsPQE!QJuDD{YS`9G`2*DB8H=Q&RE zY+|lSd0vM+my+snMV`s^_};M#`t>N#JU23ioac2V&*gf|!97#zkv-@CVLgrxUC_@@ zr1`17Q}KC@6shtgTck&skl6X+7QBDYmx6-bRXGZ<|!#54g5m=08MT z6aLbZ^VUu{$FAYO$2&p&D;Y!fZ=xCF=UIc|pN9;^mbt;#0`k;u9kF?Rw_@rx$+$^w z?J76q<}tTk)PRP2d+T`A(F$Yi8g8{x31z&g$w0SC#Vvx26qp6)N0(&IshnwDjyFa# ztY;iK!*XtsElrk=9GO{2ur;^#oKQRVpKG0=&U#59&V+=X34#=w{`gg{NmdEY?6y{GRDlWrZG;f%}iYFRCJ$ytn7X#dL!_w!r(0FfK?;MN*Xip%NF6@fY`Z5Gnuw literal 0 HcmV?d00001