From b7874ef2f89ff5b0fa7c58ca465c25b5d8714a60 Mon Sep 17 00:00:00 2001 From: Eyal Itkin Date: Wed, 26 Feb 2025 23:16:44 +0200 Subject: [PATCH] [psp] Add support for the PSP protocol PSP stands for PSP Security Protocol, and is a lightweight IPSec-Like implementation that was released by Google and is getting traction within data centers. This commit adds support for versions 0 & 1 of the protocol which use AES-GCM in 128 and 265 bits. Support was tested against the testing tool of the RFC which generated the same PCAPs that are now used for unit testing. Signed-off-by: Eyal Itkin --- scapy/contrib/psp.py | 189 ++++++++++++++++++ test/contrib/psp.uts | 86 ++++++++ test/pcaps/psp_v4_cleartext.pcap.gz | Bin 0 -> 391 bytes ...v4_encrypt_transport_crypt_off_128.pcap.gz | Bin 0 -> 1581 bytes ...encrypt_transport_crypt_off_128_vc.pcap.gz | Bin 0 -> 1592 bytes ...v4_encrypt_transport_crypt_off_256.pcap.gz | Bin 0 -> 1581 bytes 6 files changed, 275 insertions(+) create mode 100644 scapy/contrib/psp.py create mode 100644 test/contrib/psp.uts create mode 100644 test/pcaps/psp_v4_cleartext.pcap.gz create mode 100644 test/pcaps/psp_v4_encrypt_transport_crypt_off_128.pcap.gz create mode 100644 test/pcaps/psp_v4_encrypt_transport_crypt_off_128_vc.pcap.gz create mode 100644 test/pcaps/psp_v4_encrypt_transport_crypt_off_256.pcap.gz diff --git a/scapy/contrib/psp.py b/scapy/contrib/psp.py new file mode 100644 index 00000000000..ded095ed4ee --- /dev/null +++ b/scapy/contrib/psp.py @@ -0,0 +1,189 @@ +# SPDX-License-Identifier: GPL-2.0-only +# This file is part of Scapy +# See https://scapy.net/ for more information +# Copyright (C) 2025 + +# scapy.contrib.description = PSP Security Protocol +# scapy.contrib.status = loads + +r""" +PSP layer +========= + +Example of use: + +>>> payload = IP() / UDP(sport=1234, dport=5678) / Raw("A" * 9) +>>> iv = b'\x01\x02\x03\x04\x05\x06\x07\x08' +>>> spi = 0x11223344 +>>> key = b'\xFF\xEE\xDD\xCC\xBB\xAA\x99\x88\x77\x66\x55\x44\x33\x22\x11\x00' +>>> psp_packet = PSP(nexthdr=4, cryptoffset=5, spi=spi, iv=iv, data=payload) +>>> hexdump(psp_packet) +0000 04 01 05 01 11 22 33 44 01 02 03 04 05 06 07 08 ....."3D........ +0010 45 00 00 25 00 01 00 00 40 11 7C C5 7F 00 00 01 E..%....@.|..... +0020 7F 00 00 01 04 D2 16 2E 00 11 A0 C4 41 41 41 41 ............AAAA +0030 41 41 41 41 41 AAAAA +>>> +>>> psp_packet.encrypt(key) +>>> hexdump(psp_packet) +0000 04 01 05 01 11 22 33 44 01 02 03 04 05 06 07 08 ....."3D........ +0010 45 00 00 25 00 01 00 00 40 11 7C C5 7F 00 00 01 E..%....@.|..... +0020 7F 00 00 01 8E 3E 2B 13 45 C7 6B F9 5C DA C3 9B .....>+.E.k.\... +0030 86 17 62 A0 CF DF FB BE BB C6 31 3A 2B 9D E0 64 ..b.......1:+..d +0040 75 9C DD 71 C9 u..q. +>>> +>>> psp_packet.decrypt(key) +>>> hexdump(psp_packet) +0000 04 01 05 01 11 22 33 44 01 02 03 04 05 06 07 08 ....."3D........ +0010 45 00 00 25 00 01 00 00 40 11 7C C5 7F 00 00 01 E..%....@.|..... +0020 7F 00 00 01 04 D2 16 2E 00 11 A0 C4 41 41 41 41 ............AAAA +0030 41 41 41 41 41 AAAAA +>>> + +""" + +from scapy.config import conf +from scapy.error import log_loading +from scapy.fields import ( + BitField, + ByteField, + ConditionalField, + XIntField, + XStrField, + StrFixedLenField, +) +from scapy.packet import ( + Packet, + bind_bottom_up, + bind_top_down, +) +from scapy.layers.inet import UDP + +############################################################################### +if conf.crypto_valid: + from cryptography.exceptions import InvalidTag + from cryptography.hazmat.primitives.ciphers import ( + aead, + ) +else: + log_loading.info("Can't import python-cryptography v1.7+. " + "Disabled PSP encryption/authentication.") + +############################################################################### +import struct + + +class PSP(Packet): + """ + PSP Security Protocol + + See https://github.com/google/psp/blob/main/doc/PSP_Arch_Spec.pdf + """ + name = 'PSP' + + fields_desc = [ + ByteField('nexthdr', 0), + ByteField('hdrextlen', 1), + BitField("reserved", 0, 2), + BitField("cryptoffset", 0, 6), + BitField("sample", 0, 1), + BitField("drop", 0, 1), + BitField("version", 0, 4), + BitField("is_virt", 0, 1), + BitField("one_bit", 1, 1), + XIntField('spi', 0x00), + StrFixedLenField('iv', '\x00' * 8, 8), + ConditionalField(XIntField("virtkey", 0x00), lambda pkt: pkt.is_virt == 1), + ConditionalField(XIntField("sectoken", 0x00), lambda pkt: pkt.is_virt == 1), + XStrField('data', None), + ] + + def sanitize_cipher(self): + """ + Ensure we support the cipher to encrypt/decrypt this packet + + :returns: the supported cipher suite + :raise scapy.layers.psp.PSPCipherError: if the requested cipher + is unsupported + """ + if self.version not in (0, 1): + raise PSPCipherError('Can not encrypt/decrypt using unsupported version %s' + % (self.version)) + return aead.AESGCM + + def encrypt(self, key): + """ + Encrypt a PSP packet + + :param key: the secret key used for encryption + :raise scapy.layers.psp.PSPCipherError: if the requested cipher + is unsupported + """ + cipher = self.sanitize_cipher() + encrypt_start_offset = 16 + self.cryptoffset * 4 + iv = struct.pack("!L", self.spi) + self.iv + plain = b'' + to_encrypt = bytes(self.data) + self.data = b'' + psp_header = bytes(self) + header_length = len(psp_header) + # Header should always be fully plaintext + if header_length < encrypt_start_offset: + plain = to_encrypt[:encrypt_start_offset - header_length] + to_encrypt = to_encrypt[encrypt_start_offset - header_length:] + cipher = cipher(key) + payload = cipher.encrypt(iv, to_encrypt, psp_header + plain) + self.data = plain + payload + + def decrypt(self, key): + """ + Decrypt a PSP packet + + :param key: the secret key used for encryption + :raise scapy.layers.psp.PSPIntegrityError: if the integrity check + fails with an AEAD algorithm + :raise scapy.layers.psp.PSPCipherError: if the requested cipher + is unsupported + """ + cipher = self.sanitize_cipher() + self.icv_size = 16 + iv = struct.pack("!L", self.spi) + self.iv + data = self.data[:len(self.data) - self.icv_size] + icv = self.data[len(self.data) - self.icv_size:] + + decrypt_start_offset = 16 + self.cryptoffset * 4 + plain = b'' + to_decrypt = bytes(data) + self.data = b'' + psp_header = bytes(self) + header_length = len(psp_header) + # Header should always be fully plaintext + if header_length < decrypt_start_offset: + plain = to_decrypt[:decrypt_start_offset - header_length] + to_decrypt = to_decrypt[decrypt_start_offset - header_length:] + cipher = cipher(key) + try: + data = cipher.decrypt(iv, to_decrypt + icv, psp_header + plain) + self.data = plain + data + except InvalidTag as err: + raise PSPIntegrityError(err) + + +bind_bottom_up(UDP, PSP, dport=1000) +bind_bottom_up(UDP, PSP, sport=1000) +bind_top_down(UDP, PSP, dport=1000, sport=1000) + +############################################################################### + + +class PSPCipherError(Exception): + """ + Error risen when the cipher is unsupported. + """ + pass + + +class PSPIntegrityError(Exception): + """ + Error risen when the integrity check fails. + """ + pass diff --git a/test/contrib/psp.uts b/test/contrib/psp.uts new file mode 100644 index 00000000000..8d28cd73936 --- /dev/null +++ b/test/contrib/psp.uts @@ -0,0 +1,86 @@ +# PSP unit tests +# run with: +# test/run_tests -P "load_contrib('psp')" -t test/contrib/psp.uts -F + +% Regression tests for the PSP layer + +############### +##### PSP ##### +############### + ++ PSP tests + += PSP layer + +example_plain_packet = import_hexcap('''\ +0000 04 01 05 01 11 22 33 44 01 02 03 04 05 06 07 08 ....."3D........ +0010 45 00 00 25 00 01 00 00 40 11 7C C5 7F 00 00 01 E..%....@.|..... +0020 7F 00 00 01 04 D2 16 2E 00 11 A0 C4 41 41 41 41 ............AAAA +0030 41 41 41 41 41 AAAAA +''') +psp_packet = PSP(example_plain_packet) +assert psp_packet.nexthdr == 4 +assert psp_packet.hdrextlen == 1 +assert psp_packet.cryptoffset == 5 +assert psp_packet.version == 0 +assert psp_packet.spi == 0x11223344 +assert psp_packet.iv == b'\x01\x02\x03\x04\x05\x06\x07\x08' + +payload = IP(psp_packet.data) +assert payload[UDP].sport == 1234 +assert payload[UDP].dport == 5678 +assert bytes(payload[Raw]) == b"A" * 9 + += PSP Usage Example + +payload = IP() / UDP(sport=1234, dport=5678) / Raw("A" * 9) +iv = b'\x01\x02\x03\x04\x05\x06\x07\x08' +spi = 0x11223344 +key = b'\xFF\xEE\xDD\xCC\xBB\xAA\x99\x88\x77\x66\x55\x44\x33\x22\x11\x00' +psp_packet = PSP(nexthdr=4, cryptoffset=5, spi=spi, iv=iv, data=payload) +hexdump(psp_packet) +expected_orig_packet = import_hexcap(r'''\ +0000 04 01 05 01 11 22 33 44 01 02 03 04 05 06 07 08 ....."3D........ +0010 45 00 00 25 00 01 00 00 40 11 7C C5 7F 00 00 01 E..%....@.|..... +0020 7F 00 00 01 04 D2 16 2E 00 11 A0 C4 41 41 41 41 ............AAAA +0030 41 41 41 41 41 AAAAA +''') +assert bytes(psp_packet) == bytes(expected_orig_packet) +# Now let's encrypt it +psp_packet.encrypt(key) +hexdump(psp_packet) +assert bytes(psp_packet) == import_hexcap(r'''\ +0000 04 01 05 01 11 22 33 44 01 02 03 04 05 06 07 08 ....."3D........ +0010 45 00 00 25 00 01 00 00 40 11 7C C5 7F 00 00 01 E..%....@.|..... +0020 7F 00 00 01 8E 3E 2B 13 45 C7 6B F9 5C DA C3 9B .....>+.E.k.\... +0030 86 17 62 A0 CF DF FB BE BB C6 31 3A 2B 9D E0 64 ..b.......1:+..d +0040 75 9C DD 71 C9 u..q. +''') +# Now let's decrypt it back +psp_packet.decrypt(key) +hexdump(psp_packet) +assert bytes(psp_packet) == bytes(expected_orig_packet) + += PSP RFC Test - Version 0, no VC +key_128 = b'\x39\x46\xDA\x25\x54\xEA\xE4\x6A\xD1\xEF\x77\xA6\x43\x72\xED\xC4' +spi = 0x9A345678 +IV = b'\x00\x00\x00\x00\x00\x00\x00\x01' +plaintext_packet = rdpcap(scapy_path("/test/pcaps/psp_v4_cleartext.pcap.gz"))[0] +encrypted_packet = rdpcap(scapy_path("/test/pcaps/psp_v4_encrypt_transport_crypt_off_128.pcap.gz"))[0] +psp_packet = PSP(nexthdr=0x11, cryptoffset=1, spi=spi, iv=IV, data=plaintext_packet[UDP]) +psp_packet.encrypt(key_128) +assert bytes(psp_packet) == bytes(encrypted_packet[PSP]) + += PSP RFC Test - Version 1, no VC +key_256 = b'\xFA\x00\xF6\x09\xDF\x60\x20\x28\x9A\x1C\x93\xD6\x02\x70\x81\xA6\x37\xAD\x45\xB2\x4A\x55\x76\xB3\x6E\x6F\x49\xDD\x43\x11\x4D\x80' +# SPI and IV are the same as before +encrypted_packet = rdpcap(scapy_path("/test/pcaps/psp_v4_encrypt_transport_crypt_off_256.pcap.gz"))[0] +psp_packet = PSP(nexthdr=0x11, cryptoffset=1, version=1, spi=spi, iv=IV, data=plaintext_packet[UDP]) +psp_packet.encrypt(key_256) +assert bytes(psp_packet) == bytes(encrypted_packet[PSP]) + += PSP RFC Test - Version 0, with VC +encrypted_packet = rdpcap(scapy_path("/test/pcaps/psp_v4_encrypt_transport_crypt_off_128_vc.pcap.gz"))[0] +psp_packet = PSP(nexthdr=0x11, hdrextlen=2, cryptoffset=3, is_virt=1, spi=spi, iv=IV, data=plaintext_packet[UDP]) +psp_packet.encrypt(key_128) +assert bytes(psp_packet) == bytes(encrypted_packet[PSP]) diff --git a/test/pcaps/psp_v4_cleartext.pcap.gz b/test/pcaps/psp_v4_cleartext.pcap.gz new file mode 100644 index 0000000000000000000000000000000000000000..c1ea14c28273e886fc7ef6245781c32b70e8c72f GIT binary patch literal 391 zcmV;20eJo&iwFqBD>rBW18{S2Uv@NKV{Bz%a&%>QbS`jXVQ>Jua(L51CI%J;7?{P% zz`zKkIfadqlp}e-d=O^nn7L{<1A~&WODF>egDV4T4+Db(gM*-AJQq*{5Ho3~hn-`s z=(J{FWMXDvWn<^yMC+6cQE@6%&_`l#-T_m6KOcR8m$^Ra4i{)Y8_`)zddH zG%_|ZH8Z!cw6eCbwX=6{baHlab#wRd^z!!c_45x13RUz zF>}`JIdkXDU$Ah|;w4L$Enl&6)#^2C*R9{Mant54TeofBv2)k%J$v`VJCq9{>%I?*GCC003rBW18{S2Uv@NKWo~0~d2n=JbaG*Cb8v5RbYEj~d2n=JZ)Rp+ zF)}zVaARR`00HU+75db}vY`S11ONa400000001^400031000RSGGZfH3;@Cf006=T z0001pnX0=003tI)RR9P8MF0h~002M$KoKD~3IG5B3IG5CeW?TJ1)u-`5di@Knlx5; z0000000001D`!^DY=fa#kZ*34AOzC&#V4!!z)Ge!s)K*e`SlQi6QCuC%=L`_SE?sU zSD$%KuNe$e4`u5Klc?i8F_JsUtcY}Zdy#dyTcTtLEXJECxx*E=IDT`BmyL`ZYrQX? za6tzS7PvN2(a79;p0eW)u5-7%{p+3>ngSo9s@)=f3ghwncxkd2At7`@OL(~ELn=u1 zp;*qAME}U&(%mDkj-Itl- zgXG9j5ZLN=DsMm!J^4%e(t!!P0KrVY_saVw4K&X&BVLz zW4A#P-VDBPnan;FahbjBmiLV&3g_f z=RvF{L%whH#DTDO?w%HOpf(QzZF9c>SDIrUhPASF9w*F_e^xNsWb!qd$y5XME(_pg z`>*KGS*asX2r#}ZO!G)D%L9;dpTya zf3mkl+>s!SUT4zYq zOyDIyh{&LfVHfCjG=5A_p-T-bz~jVO*LbMDf&gzcoFH)iHa}13q$zc1sS$WxOG@V|D((3dSdC!I$ zEUj)a)@`8bxzr%OvdE!m%NA~p3~*cfJ~42vA0_Hh7;^#fHcs_1VVw&t^x3F^0(xi_ z=>ocUrR=)y{R7}2ac1nztqpe~B>)nWQl`D7TvC0@*&h(NazRkFS1mI z31|bTJQiiRu7Bz(wfHOCnawASrI4-YQm2@jbrVg%9`pc-+<;>0v(7J3TE;qQk2FUv zW1P>iU)_`9ND_|W^3uhYUjX`T3W4UN!MBE?O}oA2&A)oeaqrgH`64;zZ1RIf3e-Pw zMv6d^q8*R8RX3{g6^j42BGVp~MjneV2fP>e?H!eP!QO)Var;~;?hvyDG=g9N8%z1c z>?$Nck*_aJQUL`V>+33FC((k^1hV!MxTsR1RFe)IfDKOKGU=G*M2A17zWX%_;wa(PfU3yoH2??Go!B-?J@gjRNmh7HN~;JGBEK!V zeD!+ameAm8P&H2oV&+b-_j$a~=*b|nm~fs_r$Ik##SP^#cx#I~qMCeoUO2~GDkt-l zpD4<=qc9)nIDD*SFh_R literal 0 HcmV?d00001 diff --git a/test/pcaps/psp_v4_encrypt_transport_crypt_off_128_vc.pcap.gz b/test/pcaps/psp_v4_encrypt_transport_crypt_off_128_vc.pcap.gz new file mode 100644 index 0000000000000000000000000000000000000000..648ee4630ced3211d48314b1400058da2d58d55b GIT binary patch literal 1592 zcmV-82FLjyiwFqBD>rBW18{S2Uv@NKWo~0~d2n=JbaG*Cb8v5RbYEj~d2n=JZ)Rp+ zF)}z`c4IDZV_|Rr0rCY6`qaa+p#lH|000000000005&540096100|Q^Vk21$0Lldb z0Lldb004-Ys=ELHA~Qr)00;m@00q1N06+jh5g{)M0003B0007gsRQT*r~m*F0s{k@ zG*);30000000031000000000hXI9Q^gP~ZEZ*G+!1k&}zC#(9vN~SlegMZKY^$>v* zpe2aR^^E^lswYZUpLtHN84OboW$Ou(sN+2`k~_()h;(^-k#)ISqGSjx#+xX)!xgtU zeshbLjf@;?y)T|{K?e>NxHeMJ$lQCLvf~e~bGN+x>z){z0w1EP-6DPpK1p4_lJc*{AgEd($|se&>7-;LBA_uoWxDk!y)g`xC34Yp zfZHql0V*>a6or1{SjByv^G>tR0H%b3*cq@ujtTOsUuM1t-VsWMtuvl);2Axwqb%x7m`;g%Kll};2{g?bBe%i z&E?H1)+-!)IcBqevbROtkt9Y8YlSoNK1Rp1%JsRaT{oye^!n5lZH8nt zonD91>hu(O&xRW;t!^>aZJ_G8)F8gH$f0P<7H*9Ua9jI6F>tOQCF)Taa{=)-PW3Ti zoeM4W*{Fd6dT15t0=jpl?7Hs#1K=QWX6((a4R<0X01}f@roHEwR@;;Ag=8HT-L75o zA=Fw(=m+&LvQ&l%XalG`7G=1uf9fi=_$%C*%_ohekge!ar&a#ivPDF(;k&Z9*ZvrychTF9hG>&-h%sa`&=pR z5VHj|f?xm}OZmm@DkMOWuP;qf0RndU=(Sp(hvi1|Ws8XU-lMm;Eg&4Ly>d~sn zV|fQ2M`O^&UxIA%qDqHrWH$4Z*##-+ND+f{!LRD_?f8*3fP6Jn;%OFSh>AZ0dBJ%? zK^Q~^LMGv2T9r+rd;9l*4Nl@R>6qn2hd-vi`!x#UDB;$Cs>tXy00+~Z*fvT%^cK=d zR(MWIs|XSzzb(3a^?Ko!(BNuNHBSg)=1#BodA!i*$sn_saGp}9K|gH84dpR-Yl}Lf zntXU(ILBNnC-an{T_3^9)KJY8@R_RzJx%6}vo qHHt!1{t*u#nN8HwXl&DD3w0Hi%XgV}94ZJFv5hFamPzpP1poknrs`Dy literal 0 HcmV?d00001 diff --git a/test/pcaps/psp_v4_encrypt_transport_crypt_off_256.pcap.gz b/test/pcaps/psp_v4_encrypt_transport_crypt_off_256.pcap.gz new file mode 100644 index 0000000000000000000000000000000000000000..0661915d5c83bf6d98bf46ce601e324484ed958c GIT binary patch literal 1581 zcmV+|2GaQ-iwFqBD>rBW18{S2Uv@NKWo~0~d2n=JbaG*Cb8v5RbYEj~d2n=JZ)Rp+ zGBq|XaARR`00HU+75db}vY`S11ONa400000001^400031000RSGGZfH3;@Cf006=T z0001pnX0=003tI)RR9P8MF0h~002M$KoKD~3IG5B3IG5CeW?TJ1)u-`5di@Onlx5; z0000000001D`!^D697Qi(+=4B!yO_M(ey* z#hR6_{Gjy4NA;?WI`EMdS1aFx^l>?jeab`{=4Kk1V^2r4c+aXr)`VQ{|)>pCBaH|%{9U-fsU$6Nx47jbitUX-7cCX|3L&?_#P4?%-5y3#SSs%#~K(E^pmn~{QVB{?(kw{^237LlpkT|;2KBVt}U?sMo|@u zFm`U$xql|6rMyb(*K^jI^o*9WRj^s>5)_WMpoI!`yp?+f4^+tFeqC?4j3uxRyj;j_ zvmi6~-#6fmQtO8SX2?lIusgr9Zz+5Ij-d$ng2&LW?$=Z(+g@h7F6d!&6*N7$r1dz= zBDaA>Y>d#$E+#Fudb;#OEbo!vaeAu5)$`9UKhu%GWz}B1YA~dRbanj%EKF8Qb;%Vl z*qVMhqpvZPQ)yt?cvV8`NZCsOJl1g4v(xJ{HM18|tpT%YOzPkZs`Q9Ce~LYqtdbev znXv-$uR~wexGz2-1_dQ3?v&i6DH@!>>@y6ab{y;toFQ+UQ-u@d7f~0REBTCquJ?lT zWB=qitc-Q#cKTn?xcgB*!%W0e{$NA!z%D^K?|cio&o#m3PvK*!ck8=rMsH%F>(u*pMe)34~6aX}agFZ#y zS$@S^kWPrDNp*93-zt3?t4sDl|7J(_>UG|{TsB~UU$LSh-O9o*%^R``UvtKMPMR~I zjl|I*6+RJesa>^b?=@)CorvDM2;}8i2P10C+{>dsKr`#Ba?_~zxo_A=C_}+^H4>&< zQ}dha)866H%6R>8=siJB1un`&Gy~-UpIpSnb)zJ`tpTQK*G6+54!Mt2>(Cyiq*96d ztt)6DM^5(5s-rMI6dFvxsrNG`Q!#Ma9Esx=H-d(E>RiV z^EqvcWq5?|V*1dcPhwwtZ9yNQhxN|n5slYL7yb-e_WsL97!TNrLtogm`6P%pl#7Fr znM5OYsbBjw?MwN=57t64^Gcs$uG}CWu=nq7&7607T$qfsOPM#^#QnzFDL!_g8Bv}H zJVlK+k?=!RlPUJV+B;AbknaKiXQz=l2Tf#bA~V*<=hDM9M-v$KL~ z@`nHwjU&RIoGTil(Lg^%psxgf_z%ES@uoXZwR7sZFldY>IR;sk;E@*7#j7q+t2t^W fFSiDI_7;8=bMqR2ckorj9>G1gKz0U{>IDD*(tPaX literal 0 HcmV?d00001