From c44dc73b9ae2ba0746071f9e54ff9169d2cbc6c2 Mon Sep 17 00:00:00 2001 From: Brian Sipos Date: Fri, 7 Aug 2026 00:37:16 -0400 Subject: [PATCH] Add TCPCLv4 message definitions with scapy-friendly patterns AI-Assisted: no --- scapy/contrib/tcpcl.py | 477 +++++++++++++++++++ test/contrib/tcpcl.uts | 187 ++++++++ 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, 664 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..cad07470e40 --- /dev/null +++ b/scapy/contrib/tcpcl.py @@ -0,0 +1,477 @@ +# 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 Any, ClassVar, Dict, Optional, Tuple, Type +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 + +__all__ = [ + "TCPCL", + "TCPCLContact", + "TCPCLContactV3", + "TCPCLContactV4", + "TCPCLBaseMsgV4", + "TCPCLSessInit", + "TCPCLSessTerm", + "TCPCLSessExt", + "TCPCLKeepalive", + "TCPCLMsgReject", + "TCPCLXferFlag", + "TCPCLXferExt", + "TCPCLXferSegment", + "TCPCLXferAck", + "TCPCLXferRefuse", +] + +MAGIC_HEAD = b"dtn!" +"""Header magic prefix data.""" + + +class TCPCL(Packet): + """ + This is a pseudo-packet class to decode real messages + from a TCP stream based on an initial Contact Header with specific + version number. + """ + + name = "TCPCL" + match_subclass = True + + def extract_padding(self, s): + # type: (bytes) -> Tuple[bytes, Optional[bytes]] + """No payload, all extra data is padding""" + return (None, s) + + def is_consistent(self): + # type: () -> bool + """ Determine if this packet content is consistent with decoding full data. """ + return True + + @classmethod + def dispatch_hook(cls, _pkt=None, *args, **kargs): + # type: (Type[Packet], Optional[Packet], *Any, **Any) -> Type[Packet] + """ + This top dispatch is somewhat heuristic about detecting + messages outside of a proper TCP stream. + """ + if not _pkt: + return cls + pkt_cls = TCPCLContact.dispatch_hook(_pkt) + if pkt_cls is not conf.raw_layer: + return pkt_cls + return TCPCLBaseMsgV4.dispatch_hook(_pkt) + + @classmethod + def tcp_reassemble(cls, data, metadata, session): + # type: (Type[Packet], bytes, Dict[str,Any], Dict[str,Any]) -> Optional[Packet] + if len(data) < 1: + # impossible message + return None + + orig = metadata.get("original") + if orig: + tcp = orig.getlayer(TCP) + # higher port number is TCP initiator + role = "i" if tcp.sport > tcp.dport else "r" + else: + role = "unk" + + pkt = None + # first message for each role is contact header + sesskey = f"tcpcl-version-{role}" + if sesskey not in session: + pkt = TCPCLContact(data) + if isinstance(pkt, TCPCLContact): + sesskey = f"tcpcl-version-{role}" + if sesskey not in session: + session[sesskey] = int(pkt.version) + elif data[0] != ord(b"d"): + # something other than contact + pkt = TCPCLBaseMsgV4(data) + if isinstance(pkt, TCPCLBaseMsgV4) and pkt.is_consistent: + 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" + ) + pkt = None + else: + pkt = None + + else: + # seen contact already + vers = session[sesskey] + if vers == 4: + pkt = TCPCLBaseMsgV4(data) + if not pkt.is_consistent(): + pkt = None + + return pkt + + +# Well-known ports from IANA +bind_layers(TCP, TCPCL, dport=4556) +bind_layers(TCP, TCPCL, sport=4556) + + +class TCPCLContact(TCPCL): + """ + Initial stream content, separate from later messaging. + This is not a full structure but an abstract base class to dispatch from + during decoding. + """ + + fields_desc = [ + StrFixedLenField("magic", default=MAGIC_HEAD, length=4), + ByteField("version", default=None), + ] + + _reg_variants: ClassVar[Dict[int, Type["TCPCLContact"]]] = {} + """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): + # type: (Type[Packet], Optional[bytes], *Any, **Any) -> Type[Packet] + """ + Returns the right sub-class for the given data. + """ + if not _pkt: + return cls + if len(_pkt) >= 5: + magic = _pkt[:4] + if magic == MAGIC_HEAD: + vers = _pkt[4] + return cls._reg_variants.get(vers, cls) + return conf.raw_layer + + +class TCPCLContactV3(TCPCLContact): + """ + 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=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 TCPCLContactV4(TCPCLContact): + """ + Version 4 contact header from RFC 9174. + """ + + name = "TCPCLv4 Contact" + + fields_desc = [ + StrFixedLenField("magic", default=MAGIC_HEAD, length=4), + ByteField("version", default=4), + FlagsField("flags", default=0, size=8, names=["CAN_TLS"]), + ] + + +class TCPCLBaseMsgV4(TCPCL): + """ + Base class for all TCPCLv4 message types. + This is helpful for testing but most users will want to use the top + TCPCL class when decoding streams. + """ + name = "TCPCLv4 Message" + + fields_desc = [ + XByteField("msg_type", default=None), + ] + + _reg_variants: ClassVar[Dict[int, Type["TCPCLBaseMsgV4"]]] = {} + """ 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): + # type: (Type[Packet], Optional[bytes], *Any, **Any) -> Type[Packet] + """ + Returns the right sub-class for the given data. + """ + if not _pkt: + return cls + msg_type = _pkt[0] if _pkt else None + # fall-through to raw for unknown types + return cls._reg_variants.get(msg_type, conf.raw_layer) + + +TCPCL_MRU_SIZE_MAX = 2**64 - 1 +"""Largest 64-bit size value.""" + + +class _TCPCLTlvHead(Packet): + """ + Generic TLV header with data as payload. + """ + + fields_desc = [ + FlagsField("flags", default=0, size=8, names=["CRITICAL"]), + XShortField("type", default=None), + LenField("length", default=None, fmt="H"), + ] + + def extract_padding(self, s): + # type: (bytes) -> Tuple[bytes, Optional[bytes]] + """Length field is for payload, all extra data is padding""" + extlen = int(self.getfieldval("length")) + return (s[:extlen], s[extlen:]) + + +class TCPCLSessExt(_TCPCLTlvHead): + """ + Session extension header to bind layers to. + """ + + name = "TCPCL SESS_EXT" + + +class TCPCLSessInit(TCPCLBaseMsgV4): + 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"), + PacketListField( + "ext_items", + default=[], + pkt_cls=TCPCLSessExt, + length_from=lambda pkt: pkt.ext_size, + ), + ] + + def is_consistent(self) -> bool: + field_len = self.getfieldval("nodeid_length") + real_len = len(self.getfieldval("nodeid_data")) + if field_len is not None and field_len != real_len: + return False + field_len = self.getfieldval("ext_size") + real_len = sum(len(fld) for fld in self.getfieldval("ext_items")) + return not (field_len is not None and field_len != real_len) + + +class TCPCLSessTerm(TCPCLBaseMsgV4): + name = "TCPCL SESS_TERM" + + @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=["REPLY"]), + ByteEnumField( + "reason", + default=Reason.UNKNOWN, + enum={item.value: item.name for item in Reason}, + ), + ] + + +class TCPCLXferExt(_TCPCLTlvHead): + """ + Transfer extension header to bind layers to. + """ + + name = "TCPCL XFER_EXT" + + +@enum.unique +class TCPCLXferFlag(enum.IntEnum): + """ + Transfer flags. + """ + + END = 0x01 + """This segment is the end of the transfer.""" + START = 0x02 + """This segment is the start of the transfer.""" + + +class TCPCLXferSegment(TCPCLBaseMsgV4): + """ + A XFER_SEGMENT message with transfer data as field (not payload). + """ + + name = "TCPCL XFER_SEGMENT" + + fields_desc = [ + XByteField("msg_type", default=0x01), + FlagsField( + "flags", + default=0, + size=8, + names={item.value: item.name for item in TCPCLXferFlag}, + ), + LongField("transfer_id", default=None), + ConditionalField( + cond=lambda pkt: pkt.flags.START, + fld=FieldLenField("ext_size", default=None, fmt="I", length_of="ext_items"), + ), + ConditionalField( + cond=lambda pkt: pkt.flags.START, + fld=PacketListField( + "ext_items", + default=[], + pkt_cls=TCPCLXferExt, + 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), + ] + + def is_consistent(self) -> bool: + field_len = self.getfieldval("length") + if field_len is not None and field_len != len(self.getfieldval("data")): + return False + if self.flags.START: + field_len = self.getfieldval("ext_size") + real_len = sum(len(fld) for fld in self.getfieldval("ext_items")) + if field_len is not None and field_len != real_len: + return False + return True + + +class TCPCLXferAck(TCPCLBaseMsgV4): + 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 TCPCLXferFlag}, + ), + LongField("transfer_id", default=None), + LongField("ack_length", default=None), + ] + + +class TCPCLXferRefuse(TCPCLBaseMsgV4): + 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=Reason), + LongField("transfer_id", default=None), + ] + + +class TCPCLKeepalive(TCPCLBaseMsgV4): + name = "TCPCL KEEPALIVE" + + fields_desc = [ + XByteField("msg_type", default=0x04), + ] + + +class TCPCLMsgReject(TCPCLBaseMsgV4): + 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..4af09dad09c --- /dev/null +++ b/test/contrib/tcpcl.uts @@ -0,0 +1,187 @@ + ++ Direct message interface + += Contact decode with not enough data +from scapy.contrib.tcpcl import * +pkt = TCPCL(b"dtn!") +assert isinstance(pkt, conf.raw_layer) + + += TCPCLv4 contact decode +from scapy.contrib.tcpcl import * +pkt = TCPCL(bytes.fromhex("64746e2104010000")) +assert isinstance(pkt, TCPCLContactV4) +assert isinstance(pkt.payload, Padding) + += Known message decode +from scapy.contrib.tcpcl import * +data = bytes.fromhex( + "07012c" "ffffffffffffffff" "ffffffffffffffff" + "0007646174613a6869" + "0000000f" "00800000026869" "0080010003627965" +) +pkt = TCPCL(data) +assert isinstance(pkt, TCPCLSessInit) +assert isinstance(pkt.payload, NoPayload) +assert pkt.keepalive == 300 +assert pkt.nodeid_data.decode() == "data:hi" +assert len(pkt.ext_items) == 2 + += Empty message as base class +from scapy.contrib.tcpcl import * +pkt = TCPCL() +assert isinstance(pkt, TCPCL) + += Unknown message type as raw +from scapy.contrib.tcpcl import * +data = bytes.fromhex( + "ff01020304" +) +pkt = TCPCL(data) +assert isinstance(pkt, Raw) + += Session init with private extensions +from scapy.contrib.tcpcl import * +pkt = TCPCLSessInit( + keepalive=300, + nodeid_data="data:hi".encode(), + ext_items=[ + TCPCLSessExt(type=0x8000)/Raw(b"hi"), + TCPCLSessExt(type=0x8001)/Raw(b"bye"), + ] +) +assert len(pkt.ext_items) == 2 +data = bytes(pkt) +print(data.hex()) +assert data.hex() == ( + "07012c" "ffffffffffffffff" "ffffffffffffffff" + "0007646174613a6869" + "0000000f" "00800000026869" "0080010003627965" +) + += Session init with random extensions +from scapy.contrib.tcpcl import * +pkt = fuzz(TCPCLSessInit(ext_items=[ + TCPCLSessExt()/Raw(), + TCPCLSessExt()/Raw() +])) +assert len(pkt.ext_items) == 2 +data = bytes(pkt) + + ++ TCP Session interface + += read from test file starting at contact +from scapy.contrib.tcpcl import * +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) +assert isinstance(pkt, TCPCLContactV4) +pkt = packets[5].getlayer(TCPCL) +assert isinstance(pkt, TCPCLContactV4) +pkt = packets[7].getlayer(TCPCL) +assert isinstance(pkt, TCPCLSessInit) +pkt = packets[9].getlayer(TCPCL) +assert isinstance(pkt, TCPCLSessInit) + +pkt = packets[11].getlayer(TCPCL) +assert isinstance(pkt, TCPCLXferSegment) +assert pkt.transfer_id == 1 +assert pkt.flags == TCPCLXferFlag.START +assert pkt.length == 100 +pkt = packets[13].getlayer(TCPCL) +assert isinstance(pkt, TCPCLXferSegment) +assert pkt.transfer_id == 1 +assert pkt.flags == TCPCLXferFlag.END +assert pkt.length == 99 +pkt = packets[19].getlayer(TCPCL) +assert isinstance(pkt, TCPCLXferAck) +assert pkt.transfer_id == 1 + +pkt = packets[15].getlayer(TCPCL) +assert isinstance(pkt, TCPCLXferSegment) +assert pkt.transfer_id == 2 +assert pkt.flags == TCPCLXferFlag.START +assert pkt.length == 100 +pkt = packets[17].getlayer(TCPCL) +assert isinstance(pkt, TCPCLXferSegment) +assert pkt.transfer_id == 2 +assert pkt.flags == TCPCLXferFlag.END +assert pkt.length == 99 + +pkt = packets[23].getlayer(TCPCL) +assert isinstance(pkt, TCPCLSessTerm) +pkt = packets[24].getlayer(TCPCL) +assert isinstance(pkt, TCPCLSessTerm) + + += read from test file starting after contact +from scapy.contrib.tcpcl import * +conf.debug_dissector = 2 +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) +assert isinstance(pkt, TCPCLXferSegment) +assert pkt.transfer_id == 1 + += invalid session (no header or known message) +from scapy.contrib.tcpcl import * +meta = {} +session = {} +pkt = TCPCL.tcp_reassemble(b"", meta, session) +assert pkt is None + += partial contact error +from scapy.contrib.tcpcl import * +session = {} +seg = IP()/TCP(sport=45321, dport=4556)/Raw(b"dtn!") +meta = { + "original": seg +} +pkt = TCPCL.tcp_reassemble(bytes(seg.payload.payload), meta, session) +assert pkt is None + += session without a contact header +from scapy.contrib.tcpcl import * +session = {} +seg = IP()/TCP(sport=45321, dport=4556)/Raw(bytes.fromhex("04")) +meta = { + "original": seg +} +pkt = TCPCL.tcp_reassemble(bytes(seg.payload.payload), meta, session) +assert isinstance(pkt, TCPCLKeepalive) + += synthesized session with normal start +from scapy.contrib.tcpcl import * +session = {} + +seg = IP()/TCP(sport=45321, dport=4556)/Raw(bytes.fromhex("64746e2104010000")) +meta = { + "original": seg +} +pkt = TCPCL.tcp_reassemble(bytes(seg.payload.payload), meta, session) +assert isinstance(pkt, TCPCLContactV4) +assert session.get("tcpcl-version-i") == 4 +assert session.get("tcpcl-version-r") is None + +seg = IP()/TCP(sport=4556, dport=45321)/Raw(bytes.fromhex("64746e2104010000")) +meta = { + "original": seg +} +pkt = TCPCL.tcp_reassemble(bytes(seg.payload.payload), meta, session) +assert isinstance(pkt, TCPCLContactV4) +assert session.get("tcpcl-version-i") == 4 +assert session.get("tcpcl-version-r") == 4 + +seg = IP()/TCP(sport=45321, dport=4556)/Raw(bytes.fromhex("04")) +meta = { + "original": seg +} +pkt = TCPCL.tcp_reassemble(bytes(seg.payload.payload), meta, session) +assert isinstance(pkt, TCPCLKeepalive) 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