From ec85ecb2f1bd3186a8cd8a0eb6f8e1017f8a9e49 Mon Sep 17 00:00:00 2001 From: MOJ1403 Date: Fri, 27 Mar 2026 19:20:38 +0330 Subject: [PATCH] is stabel --- a.ps1 | 18 +- deploy.tar | Bin 0 -> 1024 bytes diag_uart.py | 44 ++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 175 bytes .../__pycache__/controller.cpython-312.pyc | Bin 0 -> 10548 bytes .../__pycache__/controller.cpython-313.pyc | Bin 10169 -> 10824 bytes .../__pycache__/services.cpython-312.pyc | Bin 0 -> 23420 bytes .../__pycache__/services.cpython-313.pyc | Bin 23781 -> 24269 bytes secure_sms/application/controller.py | 29 +- secure_sms/application/services.py | 428 ++++++++-------- .../core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 168 bytes .../core/__pycache__/models.cpython-313.pyc | Bin 0 -> 2315 bytes .../core/__pycache__/protocol.cpython-313.pyc | Bin 0 -> 5414 bytes .../core/__pycache__/security.cpython-313.pyc | Bin 0 -> 11636 bytes .../core/__pycache__/utils.cpython-313.pyc | Bin 0 -> 905 bytes secure_sms/core/models.py | 1 + secure_sms/core/protocol.py | 184 ++++--- secure_sms/core/security.py | 199 +++++--- secure_sms/core/utils.py | 23 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 178 bytes .../__pycache__/database.cpython-313.pyc | Bin 23788 -> 27533 bytes .../__pycache__/gsm.cpython-313.pyc | Bin 0 -> 19969 bytes secure_sms/infrastructure/database.py | 59 ++- secure_sms/infrastructure/gsm.py | 359 +++++++++---- .../__pycache__/main_window.cpython-312.pyc | Bin 0 -> 120885 bytes .../__pycache__/main_window.cpython-313.pyc | Bin 109548 -> 111513 bytes secure_sms/ui/main_window.py | 483 ++++++++++++++---- .../test_gsm_gateway.cpython-313.pyc | Bin 0 -> 1526 bytes .../test_outgoing_secure_flow.cpython-313.pyc | Bin 0 -> 5332 bytes .../test_symmetric_crypto.cpython-313.pyc | Bin 0 -> 3241 bytes tests/__tmp_outgoing_secure_flow.db | Bin 0 -> 45056 bytes ...e_flow_1c591e35ff344426aae96c4b1f2c4610.db | Bin 0 -> 45056 bytes ...e_flow_4cc013125e1c48ffa1d99090a7ad7356.db | Bin 0 -> 45056 bytes ...e_flow_51959d14b0af4620b8300d93b70c84c4.db | Bin 0 -> 45056 bytes ...e_flow_56dd9996c9c846caaf383cd8ee095bc7.db | Bin 0 -> 45056 bytes ...e_flow_5c43d3fe0ae84c5b811f65e7e2536b3c.db | Bin 0 -> 45056 bytes ...e_flow_6b2ac2e3d0a64e808e9fedbbe2b87934.db | Bin 0 -> 45056 bytes ...e_flow_9a295a01ca6e4a8eaf46939f211106b1.db | Bin 0 -> 45056 bytes ...e_flow_af5096e592964d9ba68836b78e51238b.db | Bin 0 -> 45056 bytes tests/test_gsm_gateway.py | 21 + tests/test_outgoing_secure_flow.py | 108 ++++ tests/test_symmetric_crypto.py | 51 ++ 42 files changed, 1441 insertions(+), 566 deletions(-) create mode 100644 deploy.tar create mode 100644 diag_uart.py create mode 100644 secure_sms/application/__pycache__/__init__.cpython-313.pyc create mode 100644 secure_sms/application/__pycache__/controller.cpython-312.pyc create mode 100644 secure_sms/application/__pycache__/services.cpython-312.pyc create mode 100644 secure_sms/core/__pycache__/__init__.cpython-313.pyc create mode 100644 secure_sms/core/__pycache__/models.cpython-313.pyc create mode 100644 secure_sms/core/__pycache__/protocol.cpython-313.pyc create mode 100644 secure_sms/core/__pycache__/security.cpython-313.pyc create mode 100644 secure_sms/core/__pycache__/utils.cpython-313.pyc create mode 100644 secure_sms/core/utils.py create mode 100644 secure_sms/infrastructure/__pycache__/__init__.cpython-313.pyc create mode 100644 secure_sms/infrastructure/__pycache__/gsm.cpython-313.pyc create mode 100644 secure_sms/ui/__pycache__/main_window.cpython-312.pyc create mode 100644 tests/__pycache__/test_gsm_gateway.cpython-313.pyc create mode 100644 tests/__pycache__/test_outgoing_secure_flow.cpython-313.pyc create mode 100644 tests/__pycache__/test_symmetric_crypto.cpython-313.pyc create mode 100644 tests/__tmp_outgoing_secure_flow.db create mode 100644 tests/__tmp_outgoing_secure_flow_1c591e35ff344426aae96c4b1f2c4610.db create mode 100644 tests/__tmp_outgoing_secure_flow_4cc013125e1c48ffa1d99090a7ad7356.db create mode 100644 tests/__tmp_outgoing_secure_flow_51959d14b0af4620b8300d93b70c84c4.db create mode 100644 tests/__tmp_outgoing_secure_flow_56dd9996c9c846caaf383cd8ee095bc7.db create mode 100644 tests/__tmp_outgoing_secure_flow_5c43d3fe0ae84c5b811f65e7e2536b3c.db create mode 100644 tests/__tmp_outgoing_secure_flow_6b2ac2e3d0a64e808e9fedbbe2b87934.db create mode 100644 tests/__tmp_outgoing_secure_flow_9a295a01ca6e4a8eaf46939f211106b1.db create mode 100644 tests/__tmp_outgoing_secure_flow_af5096e592964d9ba68836b78e51238b.db create mode 100644 tests/test_gsm_gateway.py create mode 100644 tests/test_outgoing_secure_flow.py create mode 100644 tests/test_symmetric_crypto.py diff --git a/a.ps1 b/a.ps1 index 69c0076..646dbd1 100644 --- a/a.ps1 +++ b/a.ps1 @@ -1,11 +1,12 @@ # ----------------------------- # تنظیمات # ----------------------------- -$LocalPath = "C:\Users\Pars\Desktop\saba-python" # مسیر پروژه روی ویندوز +$LocalPath = $PSScriptRoot # مسیر پروژه روی ویندوز (همان پوشه‌ای که اسکریپت در آن است) $PiUser = "pars" # کاربر روی Raspberry Pi -$PiHost = "192.168.1.25" # آی‌پی Raspberry Pi -$RemotePath = "/home/pars/Desktop/" # مسیر پروژه روی Pi -$MainPy = "saba-python/main.py" # فایل اصلی پایتون +$PiHost = "10.63.136.150" # آی‌پی Raspberry Pi +$RemotePath = "/home/pars/Desktop" # مسیر پروژه روی Pi +$ProjectFolder = "saba-python" # نام پوشه پروژه +$MainPy = "main.py" # فایل اصلی پایتون $KeyPath = "$env:USERPROFILE\.ssh\id_ed25519" @@ -37,16 +38,11 @@ $FolderName = Split-Path $LocalPath -Leaf $DeployTar = "$ParentDir\deploy.tar" Set-Location $ParentDir -tar.exe -cf deploy.tar --exclude=".git" --exclude="__pycache__" $FolderName +tar.exe -cf deploy.tar --exclude=".git" --exclude="__pycache__" --exclude=".runtime-venv" $FolderName scp deploy.tar "$PiUser@$PiHost`:$RemotePath/deploy.tar" -ssh "$PiUser@$PiHost" "cd $RemotePath && tar -xf deploy.tar && rm deploy.tar" +ssh "$PiUser@$PiHost" "cd $RemotePath && rm -rf $ProjectFolder && tar -xf deploy.tar && rm deploy.tar" Remove-Item $DeployTar Set-Location $LocalPath # ----------------------------- # اجرای برنامه روی Raspberry Pi -# ----------------------------- -Write-Host "Running program on Raspberry Pi..." -ssh "$PiUser@$PiHost" "cd $RemotePath && python3 $MainPy" - -Write-Host "Deployment complete! Latest version is running on Raspberry Pi." -ForegroundColor Green \ No newline at end of file diff --git a/deploy.tar b/deploy.tar new file mode 100644 index 0000000000000000000000000000000000000000..06d7405020018ddf3cacee90fd4af10487da3d20 GIT binary patch literal 1024 ScmZQz7zLvtFd70QH3R?z00031 literal 0 HcmV?d00001 diff --git a/diag_uart.py b/diag_uart.py new file mode 100644 index 0000000..1362815 --- /dev/null +++ b/diag_uart.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""UART Diagnostic - finds the correct serial port for Quectel M66.""" +import glob +import time + +try: + import serial +except ImportError: + print("ERROR: pyserial not installed. Run: pip install pyserial") + exit(1) + +# Discover all serial ports +ports = sorted(glob.glob("/dev/ttyS*") + glob.glob("/dev/ttyAMA*") + glob.glob("/dev/serial*")) +print(f"Available serial ports: {ports}\n") + +baudrates = [9600, 115200, 19200, 38400, 57600, 4800] + +for port in ports: + for baud in baudrates: + try: + ser = serial.Serial(port, baud, timeout=1.5) + ser.reset_input_buffer() + ser.write(b"AT\r\n") + time.sleep(0.5) + response = b"" + while ser.in_waiting: + response += ser.read(ser.in_waiting) + time.sleep(0.1) + ser.close() + resp_text = response.decode("ascii", errors="ignore").strip() + if resp_text: + status = "OK" if "OK" in resp_text else "RESPONSE" + print(f" ✅ {port} @ {baud} -> {status}: {repr(resp_text)}") + else: + print(f" ❌ {port} @ {baud} -> No response") + except Exception as e: + print(f" ⚠️ {port} @ {baud} -> Error: {e}") + +print("\n--- Done ---") +print("If no port responded, check:") +print(" 1. Modem power (M66 needs stable 3.8V-4.2V VBAT)") +print(" 2. TX/RX wiring (Pi TX -> M66 RX, Pi RX -> M66 TX)") +print(" 3. GND connection between Pi and M66") +print(" 4. On Pi 5: add 'dtoverlay=uart0-pi5' to /boot/firmware/config.txt") diff --git a/secure_sms/application/__pycache__/__init__.cpython-313.pyc b/secure_sms/application/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9f9b1d8da9503260a940cd146f77795bce452603 GIT binary patch literal 175 zcmey&%ge<81lMy8W`gL)AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekklO? zU22xvv1ccRy_;P_Wm0(eXCQ3VI3KA@eiGQNR83X#;U1Ziro&V=wVRL4M~dL73L!r^ z=k`Mngm<7eX;s~O`rdPI-*eCNo~wVXsj1@N=?wov+oM8E91>Zd5Ou_yLrw;ZQ7Psca>c5Ks@S_DS{-u_xp~gU z9p#Ag8b_qNHvLOOHRHB`>$mt4;I-;dJf28}QjtVlIRWp|$t1#|D8h$BsnBppkpni( zb9_)%l+XqFXecFL4vlM7M^6nJFrYS`lEY~#>o}44g;O%U6bZ{={gZlW*DHScp8%NS zWNwHj98kw!gi3D{O;JKi#CTmC) z)Nay2s-dnSZkVN(tR*$@<{|4yE!19k_dvahbP_Mrb@09lY9Hw$>xu80I8;x%Nj4Qqj*EQYbwj^m4e z^wv`QnJHmOYROCPoYbO9EjekeDy@AWbuPnqxXR-A??BZY=Yk)w3DL@)F-yP-9VVM5 z`3!%RALlZBB`jhy#dOm491OdKVSf&WU||TbK1;j{YoV`KZXa_La7>7HJ_pxjVif9( zEn_!@c~Z=X)JDWOPaKnuxIp+xkvJzE8G*LKjLfPRMj|w3DO{mFBV-)rj6CkPr_g6Y z8EelNhS^+HkF6?tF+h_aJon*mI=NJxiP6PTJI|ftK6GE^uGr6Um-&En;b zbBxeXOja~UOt}z@5ESw_=-Egp5|xSOQX;W*6s$Z6R8cempkQ)}dJ%F*lpu(ETn5hBbm+Ft_z4ddR zSN`Dzna-}ac}YZ|`r)<3dC{Zzp{_+IOwZf^dN z2<0!kU5EPYzkH2{GFqVR`ZLbm;UN;BC~g58V+Vs39M#dmc3X1} zM}YO|$V5ZwjvLLrfU2Nw!B*GRPYHkOr0wulVu-9o?0_Cv#0wjtQyhI2hQbaNQo?^~`i8=h>`!HfKFs7NspZ z4aFOYCX#LB5CAViLxwla8&h}7U&A!UQ&8=3gHF3nr^_M|)QS12AUMD~I;S)#8B!E* zW~7*1bUREs(Ns#hH4Szu$Q~eCa$k2(gA-|2rS=oU3vU`^6HWTGHzz&_}#-wiJl^`-Rdn6E>R zvJna(9u&EMwm;|VR(;*`SF*lWr=%~ceR;R{`pM~&OYXM3r)kQw!ZzRvUTzyq@=Q^_ zBvZ7KCNJquTxF5|(Kn!>T$alQ?ZYu>5z1uw_OYyI`=Yd6Cj__2d;|cPH1`AWVuXPE zGV}r?xDvf=43CE5;0t0QB_&gfCWCF77tRe7{EyY4x#RL>>#dZJNYg}DDIY6Oe-JQD z>P?~EtV){~U+Moe`at>voq8jh23(Fq31rm&2LO!v;$Fmnr=XP}v&EJ`?_N=k&jbD> zKWR&$?_v)aUK`9~#olH+$NikYYj6fkv!xq=`!z!jbDfiR7*iqKF#Ey!*iV58JrS^L z0y=6<8V;oiMQ80by?8hjjSh#x7Xy;!0*jMKqQTLPQ4@p)W<&tT#-JjnQV?h>6lv7F zL2QaCK`Mvk$R)7JEQ)X%Aq3q7NC81t-$MY?!#K)iMHmW7Bq*_=#i;EQ{!;E2EZ`C7 zRnQLWW~_F)c1iN1t8Ts7nrnVTZGI!$yl=X8N|+jb=xdwbwy^QD)49Q6b#Qpe7iQ5m z_~B!-$8z;uYJFF>zI)1L7{4W{Iq&f=O8ym8=EWUAvC06GEG7d5#$z(cv<`;xTWthc z#x|8~d$B>ZcCu_9{kk~&0C47nR+wlAP=zws7;->u;jTYF}Z$L#Y z2?#hf0sMWG=^x!-^`T{;N3l4J#cC*uA|}nv1cb%zX=P${spb{;y@>zVA! zM{_HoOES7Q98oU+#eIK*d(i!%$_1cXmAdECx5G#eEw;Qsa-bJaGDfWpl^3Z%R<;E=RCg$c}B&En= zV0$6nWl^#KiMPu6fJzBFjG{$iziX^$(g|a~W}FOP7cx#Fd}JjgnKD&~n2S&B<02&6 zEczkY)->rL#c{pERLv1v*me#w^plS7I8GV$W!_54Iq=G*rbHwMGzUv#6L8`ku&sss zSHO0f5@-oXdhG2_QaKr-GUl=`Bp~WGc}`yeEX@vy5Umly^-36eUN4z8?4YE_;P^dA zy~$TnGz=j7v?idV*X;6@FkJ_Y3I<7FA=#rRgWN0+Xb4=nWEJj(gU|W=A9dac z|M|w5eGlC0{s)p~uFj`Q8?5KOwFmmR`+cs1b@uyv+y`syzi{#Z|Dr~OvLtneo`*^L z0sxpke~z3MTRXtSryxO0#%=6uR>6}M@IV$J89f5o50E!2t$rp zI|7q8sEg#yc48nP>e5q7Wsoz7WAs}M-3OrHP_$)$g?U)rCfX7gX%D_#ghIg;7no1u z@LKLAN~gYpqfSAATo-{2kXm)$bm#nARsYt7BU%6M>ElxeA<3#IT5cR!a<9w#8fMZn zuP?e=pFH&VSKuCotnc-0=l7`(6>sl7u6>6`&0A9rDnDIW` zdaM|FWO%}3>LkOnBef>75E=BClU?jOSZLlN}6d>@i=Q`vWf-O67IT} zGUH|nh>5p-Qh-~My|7~ftgGyyeU;lQoy^!xONBYcEb%W)j^^B41vBK;g+3E1RR6);I=QE0H$8RL`Tv23Ui8jc%iG3=0Hmbca0JaT@Ip+ z)%c+11`{3~H||i%RqGf`G0|Si`|^y+G~g&Z={8YiS>w5>*9N5a;-|9Q)u!oC<2U*0_Yc~KdqFxvQm`pQM9Y!M5PvkHi zPg>CoBoX6j$VQEu*~2O`M-?FyuP{nOw|AjsqPuhhpEu(yj>hvesF0cZ-==!DEezfB zWj#k0r6bQC%q)GuK=^&`eSVIct{Aq^wUyf|-k$PP{1i80gMUO=|JlGp4Z9d;XMAM0 z^&ks;m|?+f|FI2Lz+(Vc{#5$XwDos_!nWhBkJbzsTN0InnDo0q6KS~Wq4Efol~FKO z9e}}{1G7_Cud*26snj10#fC{}@5H9k{Vv@2_4Gr|J``2<0-)pm1g26Np?GqYn?L>W znV+7?tshX=58O*<*AHIh*oMOw*_y=A2&9h-(d;|#un#j8dnPGp-7@|eegVUl{|2^J zd#>;NU?;knqti#P4^9u>Si96V_`rSQk)3n<#ENPa#0;t`Bx0vB##5<=ksj z_nIa5TJR2V8z-$mD>@uTYw=DPbQRVQG~@1+J+!5A`|mohn>Pk_isb41b`l6qXkxs4 z2f$B`rc)$wIbQw;3$y(%U=F1l3Sc<|iAEy0l=)!O9=O{Ie8>NrP&t*Rar%ca5`+$O zEqW*<6kz&3wlWNX?l&>$0TT*1^qd=pP@8@)q!0fKoMxgzrbKZB^ZPYO3X2*!IXfj3kJUpwp;I^ z7|r;26m!rlR${p|iis&YgaxK3*a4k}5Q<`f0Vzc( zrszK@`T#w+9YE;&P~6cii*7&gph&Sm=cc>|#Wn7+%_(+0u4@t-9{2HL!?J@HdzKwG zarLq!i2h|aFYds$Z}Q6yhuFOAwTWw&B~h$;T+55EU{9}s&4#do7hnC#;S$@I8y#Zj zvfmEVR5`@1WiKxd@L$2)C;7(`Yqg8*Og-760=2k@BWaLK*h7lOp4MT3=|lL7)OZpi z4~ni=TUYpdQcolf|H=R^r^7g~2mX`6{>P{PgR1TMhkkZ|JS`7gP3zvkTk%z3}$TK!vuC9pmP!EDd8bL)VaikZMMb}YQw_UqCV!v?L zz(7L6ZXrYuNd!FwwSn~(J=I&1pbY&FfeC^5(7CsE&3X9V&pr2h&Y3fJJ`R5wasP6; z92R`kvCr9!z+1OO$N^JGM=1n0)iPtH^$-TTAQ>W1FQ@@Y7UX~^Gze;h5I6)mAqI_t zTo4DRAUC*F3-v%dxW(QJ9pDk9V9YD12}u#u42kQ|B+v&*Xcpv0@(Btc`31FLgMgq` z=!6zQZO{cNXyZIXs0u1J4nh!B1C55JTT|VYL$cswamTFgp44-?p{Fy2yfL0JXb*l0 znn5W_DA6bl@xUyKsSV7{okJTft|o$?k%wyt)O1@=iDAqMjY56L2H(h`je{7}4A znGI@#ir97mbeZx_>31zMZQ&rAGQ2e-vKl<2?l zOc&KLsp>a_#|J%jSmw$7DCK6$uHs+U*0dd^Z%gU>koc~gWgFU0vdY#%3G$$_8B!$j zu<|wXjgY78Su9N$JBa-z>y^E@hmhw?>X<3bW2uytp2mf8rmwh+{*7L!MK4w` zOs^oBby>WUqby0VX`RewjFd#=&hQ8*E{eU^K&_UbfQqm^+byX z9m5(&X3_FB`Y`&qmdFILfkcFa*?3~9H_roixh!#6=CZaDtSC?@-?RIjT^^7~c+bLB GnEn9V+4vCv delta 672 zcmYk&&ubG=5C`zgnx<)fZ@X!d-E5M^O|vU{DfDK05D!iLQDk~6MbMU#*k3#nD`*=k zSSYC;oNFn1@}wYp5W!0hf_m~M1h4uB=)sEz-%@94nY+WpEO!m{D$k$YNF= zlEa)#6HQ^QjeD-2(sm4bW7OzlrU>`whs-7mm#zzFF|b63(JKj zeYO^1n+o~A@Q^IK5^bvHMHRbr&AtNn>6txG^TjS4P^?seNB&ev*I?hjTK)>qp|o=w z4yon*fv5gPB?<7Hb|)KcAvbtIF{2hKixU+~r`5SZwbqvx64(0{H>7&!>YL5~zS7ve z;7a{&)6+z`KP(Sf>-(&lmHP%GqBR&ep}nd-arQ$mWk+u43Mu*GlW%X1behAF!po z+|IV&f9?Yy1lW$7Y44Ed9GurZ_uT*g&j0`CKli8Q<)s|1I^VyYH0P|}820+YL;kRLG9YTdM

GUSUQmJLDHd?}Y!+ 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 AYXATM literal 0 HcmV?d00001 diff --git a/secure_sms/application/__pycache__/services.cpython-313.pyc b/secure_sms/application/__pycache__/services.cpython-313.pyc index 0e93f75729b92ccbfe6f1a4f92999e1f72bff052..cd51eae39206cc1f8eb3baeddea48c527c7ca918 100644 GIT binary patch literal 24269 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 L{29k0HuL`%H=ato literal 23781 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 diff --git a/secure_sms/application/controller.py b/secure_sms/application/controller.py index f7f17e9..5c3abc1 100644 --- a/secure_sms/application/controller.py +++ b/secure_sms/application/controller.py @@ -99,12 +99,16 @@ class AppController: self.service.delete_contact(phone) self._notify_ui() + def set_symmetric_key(self, phone: str, key: str): + self.service.set_symmetric_key(phone, key) + self._notify_ui() + def get_messages(self, phone: str): return self.service.get_messages(phone) - def send_message(self, phone: str, text: str) -> tuple[bool, str]: + def send_message(self, phone: str, text: str, symmetric_key: Optional[str] = None) -> tuple[bool, str]: try: - frames, mode = self.service.prepare_outgoing_message(phone, text) + frames, mode = self.service.prepare_outgoing_message(phone, text, symmetric_key=symmetric_key) except Exception as exc: return False, str(exc) @@ -114,16 +118,16 @@ class AppController: return True, "queued" def request_secure(self, phone: str) -> tuple[bool, str]: - frames = self.service.request_secure_channel(phone) - self._outbox.put({"phone": phone, "frames": frames, "msg_id": None}) - self._notify_ui(phone) - return True, "queued" + try: + ok, state = self.service.request_secure(phone) + self._notify_ui(phone) + return ok, state + except Exception as exc: + return False, str(exc) + + + - def switch_to_normal(self, phone: str) -> tuple[bool, str]: - frames = self.service.request_normal_mode(phone) - self._outbox.put({"phone": phone, "frames": frames, "msg_id": None}) - self._notify_ui(phone) - return True, "queued" def get_admin_snapshot(self) -> dict: snapshot = self.service.get_admin_snapshot() @@ -138,6 +142,9 @@ class AppController: finally: self._notify_ui(sender) + def decrypt_message_manually(self, message_id: int, key: str) -> bool: + return self.service.decrypt_message_manually(message_id, key) + def _notify_ui(self, phone: Optional[str] = None): if self.ui: self.ui.after(0, lambda: self.ui.handle_background_refresh(phone)) diff --git a/secure_sms/application/services.py b/secure_sms/application/services.py index 7e3b4c3..8754f75 100644 --- a/secure_sms/application/services.py +++ b/secure_sms/application/services.py @@ -1,3 +1,4 @@ +import os import platform from pathlib import Path from typing import Optional @@ -5,14 +6,13 @@ from typing import Optional from secure_sms.infrastructure.database import Database, utc_now from secure_sms.core.models import ContactDetails, ContactSummary, MessageView, PendingPacketView, SecureEventView from secure_sms.core.protocol import ( - build_control_frames, - build_message_frames, - decode_control_payload, - decode_plain_body, - encode_plain_body, - parse_frame, + build_nack, + build_normal_mode, + build_symmetric_msg, + parse_incoming, ) -from secure_sms.core.security import ECCCryptoService, PasswordManager, StorageCipher +from secure_sms.core.security import SymmetricCryptoService, PasswordManager, StorageCipher +from secure_sms.core.utils import normalize_phone SYSTEM_CONTACT_LABEL = "مخاطب ناشناس" @@ -22,13 +22,12 @@ class SecureMessagingService: def __init__(self, db: Database): self.db = db self.password_manager = PasswordManager() - self.crypto = ECCCryptoService() + self.crypto = SymmetricCryptoService() self.cipher: Optional[StorageCipher] = None - self.identity = None @property def unlocked(self) -> bool: - return self.cipher is not None and self.identity is not None + return self.cipher is not None def is_bootstrapped(self) -> bool: return self.db.is_bootstrapped() @@ -39,21 +38,15 @@ class SecureMessagingService: meta = self.password_manager.create_metadata(password) key = self.password_manager.derive_key(password, meta.salt) self.cipher = StorageCipher(key) - private_key, public_key, fingerprint = self.crypto.generate_identity() + self.db.set_security_metadata(meta) self.db.save_identity( - private_key_enc=self.cipher.encrypt_text(private_key), - public_key_enc=self.cipher.encrypt_text(public_key), - fingerprint=fingerprint, + private_key_enc=None, + public_key_enc=None, + fingerprint="SYMMETRIC_ONLY", ) - import os - self.db.set_connection_settings("/dev/ttyS0" if os.name != "nt" else "COM1", 115200) - self.identity = { - "private_key": private_key, - "public_key": public_key, - "fingerprint": fingerprint, - } - self.db.log_secure_event(None, "app_bootstrap", self._enc("راه‌اندازی اولیه برنامه انجام شد.")) + self.db.set_connection_settings("/dev/serial0" if os.name != "nt" else "COM1", 9600) + self.db.log_secure_event(None, "app_bootstrap", self._enc("راه‌اندازی اولیه صبا (نسخه متقارن AES-256) انجام شد.")) def unlock(self, password: str) -> bool: meta = self.db.get_security_metadata() @@ -63,14 +56,13 @@ class SecureMessagingService: return False key = self.password_manager.derive_key(password, meta.salt) self.cipher = StorageCipher(key) - identity_row = self.db.get_identity_row() - if identity_row is None: - raise ValueError("Secure identity was not found.") - self.identity = { - "private_key": self.cipher.decrypt_text(identity_row["private_key_enc"]), - "public_key": self.cipher.decrypt_text(identity_row["public_key_enc"]), - "fingerprint": identity_row["fingerprint"], - } + + # Migrate any existing legacy phone numbers to canonical format + try: + self._migrate_all_data() + except Exception as e: + print(f"[Migration] Error during data unification: {e}") + return True def verify_password(self, password: str) -> bool: @@ -91,12 +83,6 @@ class SecureMessagingService: self.db.rotate_encrypted_payloads(old_cipher, new_cipher) self.db.set_security_metadata(new_meta) self.cipher = new_cipher - identity_row = self.db.get_identity_row() - self.identity = { - "private_key": self.cipher.decrypt_text(identity_row["private_key_enc"]), - "public_key": self.cipher.decrypt_text(identity_row["public_key_enc"]), - "fingerprint": identity_row["fingerprint"], - } self.db.log_secure_event(None, "password_changed", self._enc("رمز اصلی برنامه تغییر کرد.")) def _enc(self, value: Optional[str]) -> Optional[str]: @@ -110,12 +96,17 @@ class SecureMessagingService: return self.cipher.decrypt_text(value) def add_or_update_contact(self, name: str, phone: str): + phone = normalize_phone(phone) self.db.upsert_contact(phone, self._enc(name)) def delete_contact(self, phone: str): + phone = normalize_phone(phone) self.db.delete_contact(phone) def ensure_contact(self, phone: str, fallback_name: Optional[str] = None): + phone = normalize_phone(phone) + if not phone: + return fallback = fallback_name or SYSTEM_CONTACT_LABEL self.db.ensure_contact_exists(phone, self._enc(fallback)) @@ -131,13 +122,14 @@ class SecureMessagingService: name=self._dec(row["name_enc"]) or SYSTEM_CONTACT_LABEL, mode=row["mode"], secure_state=row["secure_state"], - has_peer_key=bool(row["peer_public_key_enc"]), + has_peer_key=False, # Asymmetric is gone last_message_preview=(preview[:38] + "...") if preview and len(preview) > 38 else (preview or ""), ) ) return contacts def get_contact(self, phone: str) -> Optional[ContactDetails]: + phone = normalize_phone(phone) row = self.db.get_contact_row(phone) if not row: return None @@ -147,11 +139,13 @@ class SecureMessagingService: mode=row["mode"], secure_state=row["secure_state"], peer_fingerprint=row["peer_fingerprint"], - has_peer_key=bool(row["peer_public_key_enc"]), + has_peer_key=False, + symmetric_key=self._dec(row["symmetric_key_enc"]) if row["symmetric_key_enc"] else None, last_secure_at=row["last_secure_at"], ) def get_messages(self, phone: str) -> list[MessageView]: + phone = normalize_phone(phone) return [ MessageView( id=row["id"], @@ -165,29 +159,59 @@ class SecureMessagingService: for row in self.db.list_message_rows(phone) ] - def get_public_identity(self) -> dict: - if not self.identity: - raise RuntimeError("Application is locked.") - return { - "public_key": self.identity["public_key"], - "fingerprint": self.identity["fingerprint"], - } + # ── Outgoing Messages ────────────────────────────────────────── + + def prepare_outgoing_message(self, phone: str, text: str, symmetric_key: Optional[str] = None) -> tuple[list[str], str]: + """Prepare SMS frame(s) for an outgoing message.""" + if len(text) > 1000: + raise ValueError("طول پیام نباید بیشتر از ۱۰۰۰ کاراکتر باشد.") + phone = normalize_phone(phone) + self.ensure_contact(phone) - def prepare_outgoing_message(self, phone: str, text: str) -> tuple[list[str], str]: contact = self.db.get_contact_row(phone) - if not contact: - raise ValueError("مخاطب پیدا نشد.") - mode = contact["mode"] - if mode == "secure": - peer_key = self._dec(contact["peer_public_key_enc"]) - if not peer_key: - raise ValueError("برای این مخاطب کلید امن وجود ندارد.") - encoded_payload = self.crypto.encrypt_for_peer(text, peer_key) - return build_message_frames("S", encoded_payload), "secure" - encoded_payload = encode_plain_body(text) - return build_message_frames("N", encoded_payload), "normal" + saved_symmetric_key = ( + self._dec(contact["symmetric_key_enc"]) + if contact and contact["symmetric_key_enc"] + else None + ) + + # If the operator provides a key while sending, treat it as the contact's + # active shared key so the next secure message and any reply can reuse it. + if symmetric_key is not None: + symmetric_key = symmetric_key.strip() + if symmetric_key: + if ( + symmetric_key != saved_symmetric_key + or not contact + or contact["mode"] != "secure" + or contact["secure_state"] != "ready" + ): + self.db.update_contact_security( + phone, + mode="secure", + secure_state="ready", + symmetric_key_enc=self._enc(symmetric_key), + last_secure_at=utc_now(), + ) + else: + symmetric_key = None + + # If no key is provided for this specific message, default to normal mode. + # This allows the user to choice between "Normal" (plain text) and "Secure" + # for every outgoing message, regardless of the conversation state. + if not symmetric_key: + symmetric_key = None + + if symmetric_key: + encrypted_payload = self.crypto.encrypt_symmetric(text, symmetric_key) + frames = build_symmetric_msg(encrypted_payload) + return frames, "secure" + + # Normal mode: send plain text directly + return [text], "normal" def store_outgoing_message(self, phone: str, text: str, mode: str, transport_state: str) -> int: + phone = normalize_phone(phone) return self.db.add_message( phone=phone, direction="out", @@ -196,168 +220,89 @@ class SecureMessagingService: transport_state=transport_state, ) - def request_secure_channel(self, phone: str) -> list[str]: - self.ensure_contact(phone) - payload = { - "type": "hello", - "public_key": self.identity["public_key"], - "fingerprint": self.identity["fingerprint"], - "ts": utc_now(), - } - self.db.update_contact_security(phone, mode="normal", secure_state="pending") - self.db.log_secure_event(phone, "hello_sent", self._enc("درخواست ارتباط امن ارسال شد.")) - self.db.add_message( - phone=phone, - direction="system", - body_enc=self._enc("درخواست ارتباط امن برای مخاطب ارسال شد."), - mode="system", - transport_state="local", - ) - return build_control_frames(payload) - def request_normal_mode(self, phone: str) -> list[str]: + """Switch contact back to normal mode.""" + phone = normalize_phone(phone) self.ensure_contact(phone) - payload = { - "type": "normal_mode", - "ts": utc_now(), - } + sms = build_normal_mode() self.db.update_contact_security(phone, mode="normal", secure_state="ready") self.db.log_secure_event(phone, "normal_mode_sent", self._enc("بازگشت به حالت عادی برای مخاطب ارسال شد.")) - self.db.add_message( - phone=phone, - direction="system", - body_enc=self._enc("گفتگو به حالت عادی برگشت."), - mode="system", - transport_state="local", - ) - return build_control_frames(payload) + return [sms] + + def request_secure(self, phone: str) -> tuple[bool, str]: + """Switch contact to secure mode (symmetric).""" + phone = normalize_phone(phone) + self.ensure_contact(phone) + contact = self.db.get_contact_row(phone) + symmetric_key = self._dec(contact["symmetric_key_enc"]) if contact and contact["symmetric_key_enc"] else None + + if not symmetric_key: + raise ValueError("ابتدا باید کلید متقارن را برای این مخاطب تنظیم کنید.") + + self.db.update_contact_security(phone, mode="secure", secure_state="ready") + self.db.log_secure_event(phone, "secure_mode_enabled", self._enc("حالت امن (متقارن) فعال شد.")) + return True, "ready" + + def set_symmetric_key(self, phone: str, key: str): + phone = normalize_phone(phone) + self.ensure_contact(phone) + self.db.update_contact_security(phone, mode="secure", symmetric_key_enc=self._enc(key)) + self.db.log_secure_event(phone, "symmetric_key_set", self._enc("کلید متقارن تنظیم شد.")) def process_incoming_sms(self, sender: str, raw_text: str) -> tuple[str, Optional[list[str]]]: + """Parse and process an incoming SMS.""" + sender = normalize_phone(sender) self.ensure_contact(sender) - frame = parse_frame(raw_text) - if not frame: + msg = parse_incoming(raw_text) + + if msg.msg_type == "plain": self.db.add_message( phone=sender, direction="in", - body_enc=self._enc(raw_text), + body_enc=self._enc(msg.plain_text or ""), mode="normal", - transport_state="received_raw", + transport_state="received", ) return sender, None - payload = self._store_or_assemble_frame(sender, frame) - if payload is None: - self.db.log_secure_event( - sender, - "packet_fragment_received", - self._enc(f"بسته {frame.packet_id} در حال تکمیل است ({frame.part_no}/{frame.total_parts})."), - ) - return sender, None - - if frame.category == "control": - return sender, self._handle_control_payload(sender, payload) - else: - self._handle_message_payload(sender, frame.mode or "N", payload) - return sender, None - - def _store_or_assemble_frame(self, sender: str, frame) -> Optional[str]: - if frame.total_parts == 1: - return frame.chunk - self.db.save_fragment( - sender, - frame.packet_id, - frame.category, - frame.mode, - frame.part_no, - frame.total_parts, - frame.chunk, - ) - fragments = self.db.get_packet_fragments(sender, frame.packet_id) - if len(fragments) < frame.total_parts: - return None - payload = "".join(fragment["chunk"] for fragment in fragments) - self.db.delete_packet_fragments(sender, frame.packet_id) - return payload - - def _handle_control_payload(self, sender: str, payload: str) -> Optional[list[str]]: - data = decode_control_payload(payload) - action = data.get("type") - if action == "hello": - public_key = data.get("public_key") - fingerprint = data.get("fingerprint") or self.crypto.fingerprint_public_key(public_key) - self.db.update_contact_security( - sender, - mode="secure", - secure_state="ready", - peer_public_key_enc=self._enc(public_key), - peer_fingerprint=fingerprint, - last_secure_at=utc_now(), - ) - self.db.log_secure_event(sender, "hello_received", self._enc("درخواست ارتباط امن دریافت شد.")) - self.db.log_secure_event(sender, "secure_established", self._enc("ارتباط امن برقرار شد.")) - self.db.add_message( - phone=sender, - direction="system", - body_enc=self._enc("ارتباط امن با این مخاطب فعال شد."), - mode="system", - transport_state="local", - ) - reply = { - "type": "hello_ack", - "public_key": self.identity["public_key"], - "fingerprint": self.identity["fingerprint"], - "ts": utc_now(), - } - return build_control_frames(reply) - elif action == "hello_ack": - public_key = data.get("public_key") - fingerprint = data.get("fingerprint") or self.crypto.fingerprint_public_key(public_key) - self.db.update_contact_security( - sender, - mode="secure", - secure_state="ready", - peer_public_key_enc=self._enc(public_key), - peer_fingerprint=fingerprint, - last_secure_at=utc_now(), - ) - self.db.log_secure_event(sender, "hello_ack_received", self._enc("پاسخ ارتباط امن دریافت شد.")) - self.db.log_secure_event(sender, "secure_established", self._enc("ارتباط امن برقرار شد.")) - self.db.add_message( - phone=sender, - direction="system", - body_enc=self._enc("ارتباط امن آماده استفاده است."), - mode="system", - transport_state="local", - ) - return None - elif action == "normal_mode": + if msg.msg_type == "norm": self.db.update_contact_security(sender, mode="normal", secure_state="ready") self.db.log_secure_event(sender, "normal_mode_received", self._enc("مخاطب گفتگو را به حالت عادی برگرداند.")) - self.db.add_message( - phone=sender, - direction="system", - body_enc=self._enc("مخاطب گفتگو را به حالت عادی برگرداند."), - mode="system", - transport_state="local", - ) - return None - return None + return sender, None - def _handle_message_payload(self, sender: str, mode_marker: str, payload: str): - if mode_marker == "S": - try: - body = self.crypto.decrypt_from_peer(payload, self.identity["private_key"]) - mode = "secure" - transport_state = "received_secure" - except Exception: - body = "پیام امن دریافت شد اما بازگشایی نشد." - mode = "secure" - transport_state = "decrypt_failed" - self.db.log_secure_event(sender, "decrypt_failed", self._enc("بازگشایی پیام امن ناموفق بود.")) - else: - body = decode_plain_body(payload) - mode = "normal" - transport_state = "received" + if (msg.msg_type == "sym") or (msg.msg_type == "gsym"): + self._handle_symmetric_message(sender, msg.encrypted_payload or "", is_group=msg.is_group) + return sender, None + + if msg.msg_type == "sfra": + self._handle_fragment(sender, msg) + return sender, None + + if msg.msg_type == "nack": + print(f"[PROTO] NACK received from {sender}: pkt={msg.packet_id} part={msg.missing_part}") + return sender, None + + return sender, None + + # ── Symmetric Message Handler ────────────────────────────────── + + def _handle_symmetric_message(self, sender: str, payload: str, is_group: bool = False): + """Decrypt and store a single symmetric message (private or group).""" + try: + contact = self.db.get_contact_row(sender) + symmetric_key = self._dec(contact["symmetric_key_enc"]) if contact and contact["symmetric_key_enc"] else None + if not symmetric_key: + raise ValueError("No symmetric key") + body = self.crypto.decrypt_symmetric(payload, symmetric_key) + mode = "secure" + transport_state = "received_group" if is_group else "received_secure" + metadata_enc = None + except Exception: + body = "پیام رمزنگاری شده صبا دریافت شد اما بازگشایی نشد." + mode = "secure" + transport_state = "decrypt_failed" + metadata_enc = self._enc(payload) # Store raw payload for manual retry + self.db.log_secure_event(sender, "decrypt_failed_sym", self._enc("بازگشایی پیام ناموفق بود. کلید نامعتبر است.")) self.db.add_message( phone=sender, @@ -365,11 +310,86 @@ class SecureMessagingService: body_enc=self._enc(body), mode=mode, transport_state=transport_state, + metadata_enc=metadata_enc ) + def decrypt_message_manually(self, message_id: int, key: str) -> bool: + """Attempt to decrypt a specific message using a manually provided key.""" + row = self.db.get_message_row(message_id) + if not row or not row["metadata_enc"]: + return False + + payload = self._dec(row["metadata_enc"]) + try: + body = self.crypto.decrypt_symmetric(payload, key) + # If successful, update the main body and status + self.db.update_message_body(message_id, self._enc(body), "received_secure") + self.db.update_contact_security( + row["phone"], + mode="secure", + symmetric_key_enc=self._enc(key), + secure_state="ready", + ) + self.db.log_secure_event( + row["phone"], + "manual_decrypt_success", + self._enc("پیام با کلید وارد شده بازگشایی شد و کلید برای مخاطب ذخیره شد."), + ) + return True + except Exception: + return False + + # ── Fragment Handler ─────────────────────────────────────────── + + def _handle_fragment(self, sender: str, msg) -> Optional[list[str]]: + """Store a fragment and assemble when all parts arrive (always symmetric).""" + self.db.save_fragment( + sender, + msg.packet_id, + "message", + "SYM", + msg.part_no, + msg.total_parts, + msg.chunk, + ) + fragments = self.db.get_packet_fragments(sender, msg.packet_id) + if len(fragments) < msg.total_parts: + self.db.log_secure_event( + sender, + "fragment_received", + self._enc(f"قطعه {msg.part_no}/{msg.total_parts} دریافت شد (بسته {msg.packet_id})."), + ) + return None + + # All fragments received — assemble and decrypt + full_payload = "".join(f["chunk"] for f in fragments) + self.db.delete_packet_fragments(sender, msg.packet_id) + self._handle_symmetric_message(sender, full_payload) + return None + + def _migrate_all_data(self): + """Scan and fix any non-normalized phone numbers in the system.""" + rows = self.db.list_contact_rows() + for row in rows: + orig = row["phone"] + canonical = normalize_phone(orig) + if orig == canonical: + continue + + print(f"[Migration] Unifying legacy phone {orig} -> {canonical}") + self.db.migrate_messages_phone(orig, canonical) + self.db.migrate_fragments_phone(orig, canonical) + self.db.migrate_events_phone(orig, canonical) + + if self.db.get_contact_row(canonical): + self.db.delete_contact(orig) + else: + self.db.rename_contact_phone(orig, canonical) + + # ── Admin & Settings ─────────────────────────────────────────── + def get_admin_snapshot(self) -> dict: stats = self.db.collect_stats() - identity = self.get_public_identity() return { "stats": stats, "events": [ @@ -399,7 +419,7 @@ class SecureMessagingService: "db_path": str(Path(self.db.db_path).resolve()), "modem_port": self.db.get_connection_settings()[0], "baudrate": self.db.get_connection_settings()[1], - "fingerprint": identity["fingerprint"], + "fingerprint": "SYMMETRIC_ONLY", }, } diff --git a/secure_sms/core/__pycache__/__init__.cpython-313.pyc b/secure_sms/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74499160ea36253537496ec582759ee4fda713ec GIT binary patch literal 168 zcmey&%ge<81lMy8W`gL)AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkl=AXRDad;?$zz zn1Dne;gVXMU6NlAlboNDs#~0xlo$gdbPFm=GV=3cic^zIi&Eo@bBloric(|Z<1_Oz mOXB183My}L*yQG?l;)(`6|n-%1=(8+Vtiy~WMnL22C@Klb}SG8 literal 0 HcmV?d00001 diff --git a/secure_sms/core/__pycache__/models.cpython-313.pyc b/secure_sms/core/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44af84fc577873b2675b53662d3441f6dad95e37 GIT binary patch literal 2315 zcmbtV%Wm676dj5bNr`&+A=ygZ27aJTRKV#fi@vgPf}&0n8zGt%fdMT|9KsUC&Wzk@ z(~W_&TQ36W7rN~?^aBbA2!sI)6zIx36<&1PdxxYQ#7cmm1ax%ooSEUwxes1ct7QVu z*^@t_pG`vkMkjxYnQ`$u7%z!WeB&Y6HMp^kcHv=R*W~6p35*umB)<6p@vXasbj{st zDYr7V2&{dLEoQ6(tb2{MGqwb5`5NnFtOso68tWcb+qH8%McYuNZYaY}FBAeCcQ29A zAP#$-?2P&k?yjGUr||cZ1cVzt;RWB|=Eo$kf}&rz1&p@LOUt^nirTVao{g68a7WK| zwO8WqVY%(8+Rh+`lSqHq?}vPJjtZ+{@_Z08WyN71us`TBRS~Q+^NdS{ze|ROUk7bx1m&(DTS?uJ+v#7{xA%C8B?e9S;U3x zMKOy9K$}8x1*2gc58#C$j(}0?U@++M8lJF8^+}=*4W;DK!B8@o!Rx?Xz>la0J6{Cf z3&usT9|GKG;z$mXpaXZ=7U4k{AZ#Zi2{#S$yY&T3;4Hu!_r#rK#BJD11L`7f1N=h% z@@gl$({lCX+Z2|X6RSC0TA5fY)Ah|it=4$!m9@!R`O^7O{R!y*5mgHhl*cx3z;d#9 z=ZubXUy07< zxvv6I^z%}4bg6ld()xG`wQ-IoQH7|hHHUT(an`{+$0YBLwEn6tc^Hdiz-3l;BoC>q{q(UrX_3Yhtyg3(GI| zQXO&Od>!ur^9~)cZN0Tw`8Kx?owlp$zHT-Tj#(_VT6jK=`K!td2dNw-ES1jbrWu?G z;>;7|wUnEu#izJC0=`CIK7tkt%O`tqV*o8kb>YQ#>D_VS{JS3j^PYFVD$g{Nu#jzC z$X6wen&p|4HtofI7I&c{_ruN+OBK2U@6K{5(W5l#DlapSqWH?u?Ysu*#-d|Lr6lB1 zC{IG4?PX3`H)ARoi{Gb^u{+K!jRA0&ro1JJarxCR}54MgTGb-4#tZMq@?*G6LO(CM;ud TMgTGb-P-jP{}f<_PPP94mO@+2{)j>_IK zmJAnRk&no zu}g~0&c$O>F(oQW3$wF|PGgaP!!fF8nmnzD&n?U;+JHk8Pe=n}7sf|#h~K4sp1Lhc z*J65PM${9cnxM0CT%1irl@cRqczp22(YdR7I2Jty1DWd3=?PtqLm!oO`B-@uWHe0W z=~+e9#l)02n~dwR9$X;KmL=@cHpOOQNS?et0>T2j*Wt^{u&dqQhT0dT;1gu{j-90u1&#&PTlhhI#}@$!r}9G;3%O^?S^MNPo8H5`s6A~4`Jof)`)alDdNI z8enc6e#AI)Y9JKU6sm>ra6_jR?W&%b3q`=Ado=lq9751Dx1i4?)R4v^O<0@NfPgBY zxpKJaom-$7fEmDR(XIh_mHb`USMc~=87(yVQ)koBWx-!~vO6y{W#)7ItsgH7-G$bU z)VcJ0mcI#K9fe(OsWa(`OyuU}WudLmwmUVF*^%wIwQE_}U1)Ai4W$ofPThQFS!gY^ z?@9e2)0OSN)w3+@DTtk^^BG&V;g9V>J<;Je_b;Webl zs;3b&3Fe*6d7-(q5-%(J&E#)2~`mj6vA~mT};7;ELNke9FH&5gv)Yx?fpi;xEl~? zCvZ3TxS|^F(ZPu@_*VGT*}-wi@Rp8asvOR>fQebDAxtIXaScyg6e&Bnh7x6U%P_bm z-3J8t)A|9V$UoW9J(u@)=bzY@clM-CuUZ}H1FtOH9$mJ!73vz(FQwh9PJh06-?FnO zFZ8hE-TGwF7`HxIb}su%=K^O?$`zPHONYXO#1@A^F(P3MJ`*mPMu20wYIrqeCCkUb zSTF_qVD13?v?c(k;I{Ph89l$VC*RzgclPClKBfW&9hT9UXGh9~2P`23U7eYV4*T4J z>wbnqW5X70hmC9Oc)WI9i3Ez7O8GKgHJ$PlbXX_}L^VxXDmd^3zTc$sllBTm5+*SQ zv9@VklW5bd9W0w^1Gyw<1cgny9-tP;Mbjt*DksRTnN*=h7vO@0 zSs$mGYxXl&j9IlzqIm{4Umv@CbPr@0@%?EkV!xA(x2xT7b&Bhi6ZQx*b zki=h>gja2-2}|dmA2RF;?5|K+R|0nW6p%2}F)XDD40=|M7+lP-u+#-9jO8=P7}0r_ zEHu30YJtMMl2E;SCF^-=2B#&5rE!8~c9c{n&SS-OPI|pZgA{2a7zh*JavnCzk4WFWdHf z>N|WN=e{Dm$5XTrr#szPv=Xy(!$#b78Ov)UsUcw3+?gBx*x$Wj=3SQ0{oOaur%wSR zSADuG)0P>|9M0~~O1b^7|1f(l_iQein_hf&F}XOsY&-O+zxxYZR3yCF^7R)^*y`(b z2Ou;w+YfwAC;NmyxLQvhu&|8zfWu)ygC@O>*<`B-BAJ357?2s_2#*N^OGt`dMmqpN z1Z3G6Gw}lf&`}D2Dij)Y2qw=W0oHBRl#xA>?FL0|#dKh-(IWHrXEo^JIQu| zlk7n*$V(2CN^&0ILD#K0yB^^@4kyP;Zd^HD4WXM?BzYun<&CKdo2L@2>L@t|CLqg0 z^lGU+^*WG9kbG#UlW1$K0j3@?6)7H=X{en6N-us(jYlk<|_*OE-J!XDSWj?Uu zdJlVFJp6uu@2D76bcMo;LJ?!SCgNM7*D$lXA3Pnskd@nGv>k?qfOTO7k5|r$2B_>8 zW({E);2K(dfG;g%1Q?L6o7wAm4R3`-k~MT~L(m|zuxvpO!S@<^bHGxwmIWHrz2*gQ znythr19cmdk8M+pNsw+F@Gzjd25t>^;mWg&f<2Ql_-SuJyn@`~-M->& zU-Gv9^6;{EU+QeZ*|6elS#q|#`O=DW&ysV`ujg02J9E?TcP)8OjUkYb(~)C2K1#oVq)|^ts)zct6b-gVz?-BDj8R$qP5OkB-H|NpU1kW6L_>+)ba>H5Pg3|GVLT81;>Y zcJarjyN8aG5B>gOFZuAeW7x%ic8) zQ|>}tJ%IYwT+dQ{U&;$ulk+Q+gWFvs0M>cVm&cd;MNo9n%M z;C=o#M>cR+tTS;><+LJ!Zc*F7?!LptS+Y`*K({z?H}dXfHvE0`&;1|7zCdC}19E({iU0rr literal 0 HcmV?d00001 diff --git a/secure_sms/core/__pycache__/security.cpython-313.pyc b/secure_sms/core/__pycache__/security.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c644784c22629e90b940cc60850416293c0a29c0 GIT binary patch literal 11636 zcmbU{TW}lKb$78?ydNY;kQ7Og%ZDUF5~&9tvKUzsMUj?7k+!jdO_#LLCAcJEg8;J& zS|Z!HOg-aJjvYyM9KlVdrkk{(GI`KR+t7_?qDhlZJ~|!1P)c;oWHhe(!LP!hI?gyB z?KyX`07=1d+dIIyXU~1^J&$|txx49f+6bf#;TPdw?Iz^k@j*|H9J2W?L&)nyAObT$ zWG!c8hCUl)1I<~PrMXcyLT(t~E|_E!k9FAr^974+p}29tdch{!D9#PoFF0fePez@7 z7Z2u{FV6uN~Hz)J-W zz%rp6pjW5>SSeHi+$B^4tPyGf?iT6*)(bqq2B8sP6K&f}+m;IzLX}W0)CzS1FEk2! z+AMucC#(@Bxm?&g>J|2ldOD0kOEr1MC|3x+Q4dhOK-o_zm0KtWD5YxDR7DzzaIl&P zt@@jGYF!d}7cFapvO{{ATuo_*fp#QMtD&@`Kx@y_Y9|?A#|HK^xL#DEWG@|!YdyveC30M>;L(apUxsP{_O>t zskgPKmuU9>%i7a5nq_ijU((7Jw6Z0YIjmuCsmu`#`zw_>s$t($nRX5PmdbQ!*k7wm zr-uEF%5-Viw^inthApeiLmGBlWsXzq{S_^H+h@^`A8Of;wCr!S?C-Sf@3rj5TDGcX z@2EyxBGn{I@mV<<%CQyKkFdG_<}-l2PDYSMlRqb<`ZMN@BtUeb@N-*BG#msv*SFtX zq|~67HWME^e=*>{*s6#V5`RWiq^=HLiiTq2;pilPB^;lM&Bpl|aU7oy^3!5GG=(Mo zk=b}$lJ_ZmczPxxO-s?Zi0p1tIZ+9P!z%Nz�QlXoqU)tQ^4sf@57Bv~x){jY}{H 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 e`f)l||| — Fragment of multi-part message (symmetric) + @S:SYM| — Single encrypted message (symmetric) + @S:NACK|| — Retransmission request + (no prefix) — Plain text message (normal mode) +""" import uuid from dataclasses import dataclass from typing import Optional -from secure_sms.core.security import b64u_decode, b64u_encode +PREFIX = "@S:" +G_PREFIX = "@G:" - -FRAME_PREFIX = "@SSM1" -FRAME_CHUNK_SIZE = 92 +# Maximum SMS body size in characters +MAX_SMS_CHARS = 140 +FRAG_OVERHEAD = 25 +FRAG_CHUNK_SIZE = MAX_SMS_CHARS - FRAG_OVERHEAD @dataclass -class ParsedFrame: - category: str - packet_id: str - part_no: int - total_parts: int - chunk: str - mode: Optional[str] = None +class ParsedMessage: + """Result of parsing an incoming SMS.""" + msg_type: str # "norm", "sym", "sfra", "nack", "plain", "gsym" + is_group: bool = False + encrypted_payload: Optional[str] = None + packet_id: Optional[str] = None + part_no: Optional[int] = None + total_parts: Optional[int] = None + chunk: Optional[str] = None + missing_part: Optional[int] = None + plain_text: Optional[str] = None -def _split_payload(encoded_payload: str) -> list[str]: - return [ - encoded_payload[index:index + FRAME_CHUNK_SIZE] - for index in range(0, len(encoded_payload), FRAME_CHUNK_SIZE) +# ── Builders ──────────────────────────────────────────────────────── + +def build_normal_mode() -> str: + return f"{PREFIX}NORM|" + + +def build_symmetric_msg(encrypted_payload: str) -> list[str]: + """Build one or more SMS for a symmetrically encrypted payload.""" + full = f"{PREFIX}SYM|{encrypted_payload}" + if len(full) <= MAX_SMS_CHARS: + return [full] + return build_fragments(encrypted_payload) + + +def build_group_msg(encrypted_payload: str) -> str: + """Build a single SMS for a group encrypted payload.""" + return f"{G_PREFIX}SYM|{encrypted_payload}" + + +def build_fragments(payload: str, packet_id: Optional[str] = None) -> list[str]: + """Split a payload into numbered fragments (always symmetric in v3).""" + pkt_id = packet_id or uuid.uuid4().hex[:10] + prefix = f"{PREFIX}SFRA" + chunks = [ + payload[i:i + FRAG_CHUNK_SIZE] + for i in range(0, len(payload), FRAG_CHUNK_SIZE) ] or [""] - - -def encode_plain_body(text: str) -> str: - return b64u_encode(text.encode("utf-8")) - - -def decode_plain_body(encoded_text: str) -> str: - return b64u_decode(encoded_text).decode("utf-8") - - -def encode_control_payload(payload: dict) -> str: - packed = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") - return b64u_encode(packed) - - -def decode_control_payload(encoded_payload: str) -> dict: - return json.loads(b64u_decode(encoded_payload).decode("utf-8")) - - -def build_control_frames(payload: dict, packet_id: Optional[str] = None) -> list[str]: - packet_id = packet_id or uuid.uuid4().hex[:10] - encoded_payload = encode_control_payload(payload) - parts = _split_payload(encoded_payload) + total = len(chunks) return [ - f"{FRAME_PREFIX}|CTL|{packet_id}|{index + 1}|{len(parts)}|{chunk}" - for index, chunk in enumerate(parts) + f"{prefix}|{pkt_id}|{idx + 1}|{total}|{chunk}" + for idx, chunk in enumerate(chunks) ] -def build_message_frames(mode: str, encoded_payload: str, packet_id: Optional[str] = None) -> list[str]: - packet_id = packet_id or uuid.uuid4().hex[:10] - parts = _split_payload(encoded_payload) - return [ - f"{FRAME_PREFIX}|MSG|{mode}|{packet_id}|{index + 1}|{len(parts)}|{chunk}" - for index, chunk in enumerate(parts) - ] +def build_nack(packet_id: str, missing_part: int) -> str: + return f"{PREFIX}NACK|{packet_id}|{missing_part}" -def parse_frame(raw_text: str) -> Optional[ParsedFrame]: - if not raw_text.startswith(FRAME_PREFIX): - return None - if raw_text.startswith(f"{FRAME_PREFIX}|CTL|"): - parts = raw_text.split("|", 5) - if len(parts) != 6: - return None - _, _, packet_id, part_no, total_parts, chunk = parts - return ParsedFrame( - category="control", - packet_id=packet_id, - part_no=int(part_no), - total_parts=int(total_parts), - chunk=chunk, +# ── Parser ────────────────────────────────────────────────────────── + +def parse_incoming(raw_text: str) -> ParsedMessage: + """Parse an incoming SMS and determine its type.""" + is_group = raw_text.startswith(G_PREFIX) + if not raw_text.startswith(PREFIX) and not is_group: + return ParsedMessage(msg_type="plain", plain_text=raw_text) + + # Strip prefix + prefix_len = len(G_PREFIX) if is_group else len(PREFIX) + body = raw_text[prefix_len:] + + if body.startswith("NORM|"): + return ParsedMessage(msg_type="norm", is_group=is_group) + + elif body.startswith("SYM|"): + return ParsedMessage( + msg_type="sym" if not is_group else "gsym", + is_group=is_group, + encrypted_payload=body[4:], ) - if raw_text.startswith(f"{FRAME_PREFIX}|MSG|"): - parts = raw_text.split("|", 6) - if len(parts) != 7: - return None - _, _, mode, packet_id, part_no, total_parts, chunk = parts - return ParsedFrame( - category="message", - mode=mode, - packet_id=packet_id, - part_no=int(part_no), - total_parts=int(total_parts), - chunk=chunk, - ) - return None + + elif body.startswith("SFRA|"): + parts = body[5:].split("|", 3) + if len(parts) == 4: + try: + return ParsedMessage( + msg_type="sfra", + is_group=is_group, + packet_id=parts[0], + part_no=int(parts[1]), + total_parts=int(parts[2]), + chunk=parts[3], + ) + except ValueError: + pass + + elif body.startswith("NACK|"): + parts = body[5:].split("|", 1) + if len(parts) == 2: + try: + return ParsedMessage( + msg_type="nack", + is_group=is_group, + packet_id=parts[0], + missing_part=int(parts[1]), + ) + except ValueError: + pass + + return ParsedMessage(msg_type="plain", plain_text=raw_text) diff --git a/secure_sms/core/security.py b/secure_sms/core/security.py index e90ac55..7a7fa46 100644 --- a/secure_sms/core/security.py +++ b/secure_sms/core/security.py @@ -1,23 +1,88 @@ import base64 import hashlib import os +import re +import unicodedata from dataclasses import dataclass from typing import Optional -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import x25519 from cryptography.hazmat.primitives.ciphers.aead import AESGCM -from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.kdf.scrypt import Scrypt +_LEGACY_INVISIBLE_CHARS = dict.fromkeys(map(ord, "\u200c\u200d\u200e\u200f\ufeff"), None) +_ARABIC_VARIANT_TRANSLATION = str.maketrans( + { + "ك": "ک", + "ي": "ی", + "ى": "ی", + "ة": "ه", + "ۀ": "ه", + "٠": "0", + "١": "1", + "٢": "2", + "٣": "3", + "٤": "4", + "٥": "5", + "٦": "6", + "٧": "7", + "٨": "8", + "٩": "9", + "۰": "0", + "۱": "1", + "۲": "2", + "۳": "3", + "۴": "4", + "۵": "5", + "۶": "6", + "۷": "7", + "۸": "8", + "۹": "9", + } +) + + def b64u_encode(data: bytes) -> str: + """URL-safe Base64 encoding without padding, matching Flutter's implementation.""" return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=") +def _decode_transport_payload(value: str) -> bytes: + """Decode either legacy Base64URL payloads or the new hex transport format.""" + if value.startswith("h1:"): + clean_hex = re.sub(r"[^0-9A-Fa-f]", "", value[3:]) + if len(clean_hex) % 2 != 0: + clean_hex = clean_hex[:-1] + return bytes.fromhex(clean_hex) if clean_hex else b"" + return b64u_decode(value) + + def b64u_decode(value: str) -> bytes: - padding = "=" * (-len(value) % 4) - return base64.urlsafe_b64decode((value + padding).encode("ascii")) + """Universal Base64 decode with robust cleanup and padding repair. + Handles both standard and URL-safe Base64, ensuring both English (short) + and Persian (long) messages are decoded correctly. + """ + # 1. Strip all whitespace and modem artifacts + clean = "".join(value.split()) + # 2. Normalize characters (URL-safe to Standard) + clean = clean.replace("-", "+").replace("_", "/") + # 3. Strip existing padding to avoid double-padding issues + clean = clean.split("=")[0] + # 4. Filter only valid Base64 characters (A-Z, a-z, 0-9, +, /) + clean = re.sub(r'[^A-Za-z0-9+/]', '', clean) + + # 5. Add correct padding for decoding + padding_len = (4 - len(clean) % 4) % 4 + padded = clean + ("=" * padding_len) + + # DEBUG: Show what happened to the payload + # print(f"[B64] Original: {value[:16]}... Padded: {padded[:16]}... (len={len(padded)})") + + try: + return base64.b64decode(padded.encode("ascii")) + except Exception as e: + print(f"[B64] ERROR decoding: {e}") + return b"" @dataclass @@ -73,61 +138,79 @@ class StorageCipher: return plaintext.decode("utf-8") -class ECCCryptoService: - INFO = b"sms-secure-channel-v2" +class SymmetricCryptoService: + """ + Symmetric encryption service using AES-GCM-256. + Matches the Flutter implementation while keeping compatibility with + older Python builds that normalized keys before hashing. + """ - def generate_identity(self) -> tuple[str, str, str]: - private_key = x25519.X25519PrivateKey.generate() - public_key = private_key.public_key() - private_raw = private_key.private_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PrivateFormat.Raw, - encryption_algorithm=serialization.NoEncryption(), - ) - public_raw = public_key.public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw, - ) - public_b64 = b64u_encode(public_raw) - return b64u_encode(private_raw), public_b64, self.fingerprint_public_key(public_b64) + def _clean_key_variants(self, password: str) -> list[tuple[str, str]]: + raw = password.strip() + legacy_nfc = unicodedata.normalize("NFC", raw) + visual_safe = raw.translate(_LEGACY_INVISIBLE_CHARS).translate(_ARABIC_VARIANT_TRANSLATION) + legacy_nfc_visual_safe = legacy_nfc.translate(_LEGACY_INVISIBLE_CHARS).translate(_ARABIC_VARIANT_TRANSLATION) - def fingerprint_public_key(self, public_key_b64: str) -> str: - digest = hashlib.sha256(b64u_decode(public_key_b64)).hexdigest() - return digest[:16].upper() + variants: list[tuple[str, str]] = [] + seen: set[str] = set() + for label, value in ( + ("flutter_raw", raw), + ("legacy_python_nfc", legacy_nfc), + ("visual_safe", visual_safe), + ("legacy_python_nfc_visual_safe", legacy_nfc_visual_safe), + ): + if value not in seen: + variants.append((label, value)) + seen.add(value) + return variants - def encrypt_for_peer(self, message: str, peer_public_key_b64: str) -> str: - peer_public = x25519.X25519PublicKey.from_public_bytes(b64u_decode(peer_public_key_b64)) - ephemeral_private = x25519.X25519PrivateKey.generate() - ephemeral_public = ephemeral_private.public_key().public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw, - ) - shared_key = ephemeral_private.exchange(peer_public) - derived_key = HKDF( - algorithm=hashes.SHA256(), - length=32, - salt=None, - info=self.INFO, - ).derive(shared_key) + def _derive_symmetric_key_from_text(self, key_text: str, *, debug: bool = False) -> bytes: + key = hashlib.sha256(key_text.encode("utf-8")).digest() + if debug: + print(f"[Crypto] Derived key fingerprint: {key.hex()[:4]}...") + return key + + def _derive_symmetric_key(self, password: str) -> bytes: + """Derive the primary 32-byte key exactly like Flutter: SHA-256(trimmed text).""" + raw_trimmed = password.strip() + return self._derive_symmetric_key_from_text(raw_trimmed, debug=True) + + def encrypt_symmetric(self, message: str, password: str) -> str: + """Encrypt message using AES-GCM with a password-derived key.""" + key = self._derive_symmetric_key(password) + aesgcm = AESGCM(key) nonce = os.urandom(12) - ciphertext = AESGCM(derived_key).encrypt(nonce, message.encode("utf-8"), None) - return b64u_encode(ephemeral_public + nonce + ciphertext) + ciphertext = aesgcm.encrypt(nonce, message.encode("utf-8"), None) + # SMS-safe transport: explicit hex payload, still backward-compatible on decode. + return "h1:" + (nonce + ciphertext).hex() - def decrypt_from_peer(self, payload_b64: str, private_key_b64: str) -> str: - payload = b64u_decode(payload_b64) - if len(payload) < 60: - raise ValueError("Secure payload is too short.") - ephemeral_public_raw = payload[:32] - nonce = payload[32:44] - ciphertext = payload[44:] - private_key = x25519.X25519PrivateKey.from_private_bytes(b64u_decode(private_key_b64)) - ephemeral_public = x25519.X25519PublicKey.from_public_bytes(ephemeral_public_raw) - shared_key = private_key.exchange(ephemeral_public) - derived_key = HKDF( - algorithm=hashes.SHA256(), - length=32, - salt=None, - info=self.INFO, - ).derive(shared_key) - plaintext = AESGCM(derived_key).decrypt(nonce, ciphertext, None) - return plaintext.decode("utf-8") + def decrypt_symmetric(self, payload_b64: str, password: str) -> str: + """Decrypt message using AES-GCM with a password-derived key.""" + payload = _decode_transport_payload(payload_b64) + + if len(payload) < 28: + print(f"[Symmetric] ERROR: Payload too short ({len(payload)})") + raise ValueError("Symmetric payload is too short.") + + nonce = payload[:12] + ciphertext_with_tag = payload[12:] + + last_error: Optional[Exception] = None + tried_labels: list[str] = [] + for label, key_text in self._clean_key_variants(password): + tried_labels.append(label) + key = self._derive_symmetric_key_from_text(key_text, debug=(label == "flutter_raw")) + aesgcm = AESGCM(key) + try: + plaintext = aesgcm.decrypt(nonce, ciphertext_with_tag, None) + if label != "flutter_raw": + print(f"[Symmetric] Compatibility decrypt succeeded using: {label}") + return plaintext.decode("utf-8") + except Exception as exc: + last_error = exc + + print(f"[Symmetric] Decryption FAILED after trying: {', '.join(tried_labels)}") + print("[Symmetric] Check: Key and payload must match bit-perfectly.") + if last_error is not None: + raise last_error + raise ValueError("Symmetric decryption failed.") diff --git a/secure_sms/core/utils.py b/secure_sms/core/utils.py new file mode 100644 index 0000000..0ea5e59 --- /dev/null +++ b/secure_sms/core/utils.py @@ -0,0 +1,23 @@ +def normalize_phone(phone: str) -> str: + """Normalize phone number to a canonical 09XXXXXXXXX format for Iranian numbers. + Handles +98, 0098, 9, and 09 prefixes by focusing on the last 10 digits. + """ + if not phone: + return "" + + # Remove all non-digit characters + digits = "".join(filter(str.isdigit, phone)) + + # If no digits are found, return the original string stripped of whitespace. + # This handles cases like "abc" -> "abc", " " -> "" + if not digits: + return phone.strip() + + # Standard Iranian mobile numbers end with 10 digits starting with '9' + # Example: 9123456789. We want to normalize this to 09123456789. + if len(digits) >= 10: + suffix = digits[-10:] + if suffix.startswith("9"): + return "0" + suffix + + return digits diff --git a/secure_sms/infrastructure/__pycache__/__init__.cpython-313.pyc b/secure_sms/infrastructure/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f32cca775feb9f4e833c6241acf75a1498dced43 GIT binary patch literal 178 zcmey&%ge<81lMy8W`gL)AOZ#$p^VQgK*m&tbOudEzm*I{OhDdekkqX}XRDad;?$zz zn1Dne;gVXMU6NlAlboNDs#~0xlo$gdbPFm=GV=3cic^zIi&Eo@bBkj#^U{hEi%W`1 wlS_a?G4b)4d6^~g@p=W7w>WHa^HWN5QtgUZfi{3#Pz+*xWM*V!EMf+-0H(k&asU7T literal 0 HcmV?d00001 diff --git a/secure_sms/infrastructure/__pycache__/database.cpython-313.pyc b/secure_sms/infrastructure/__pycache__/database.cpython-313.pyc index 228c0cf5f610b07a8b7f90dd55137a671d046080..3dc5d7c2ac273a4eaa1d36fb55477c7f8e7a1440 100644 GIT binary patch delta 5290 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 Y;-i)9Ul`~;x*`1#M+zP@h{}8XUxnP!xc~qF diff --git a/secure_sms/infrastructure/__pycache__/gsm.cpython-313.pyc b/secure_sms/infrastructure/__pycache__/gsm.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c7792acd32f2dd4e666638f6a14df93e2c7fe811 GIT binary patch literal 19969 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 R-ldcsS*!Xpg&~>d{{vnolNJB~ literal 0 HcmV?d00001 diff --git a/secure_sms/infrastructure/database.py b/secure_sms/infrastructure/database.py index bcc0696..5f61d79 100644 --- a/secure_sms/infrastructure/database.py +++ b/secure_sms/infrastructure/database.py @@ -54,6 +54,7 @@ class Database: secure_state TEXT NOT NULL DEFAULT 'none', peer_public_key_enc TEXT, peer_fingerprint TEXT, + symmetric_key_enc TEXT, last_secure_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -103,6 +104,22 @@ class Database: ) conn.commit() + # --- Migrations --- + # Ensure columns exist in older databases + cursor = conn.cursor() + cursor.execute("PRAGMA table_info(contacts)") + cols = [c["name"] for c in cursor.fetchall()] + if "symmetric_key_enc" not in cols: + conn.execute("ALTER TABLE contacts ADD COLUMN symmetric_key_enc TEXT") + if "last_secure_at" not in cols: + conn.execute("ALTER TABLE contacts ADD COLUMN last_secure_at TEXT") + + cursor.execute("PRAGMA table_info(messages)") + cols = [c["name"] for c in cursor.fetchall()] + if "metadata_enc" not in cols: + conn.execute("ALTER TABLE messages ADD COLUMN metadata_enc TEXT") + conn.commit() + def is_bootstrapped(self) -> bool: return self.get_security_metadata() is not None @@ -138,11 +155,13 @@ class Database: def get_connection_settings(self) -> tuple[str, int]: import os - default_port = "/dev/ttyS0" if os.name != "nt" else "COM1" + default_port = "/dev/serial0" if os.name != "nt" else "COM1" port = self.get_config("gsm_port") if not port or port == "COM1": port = default_port - baudrate = int(self.get_config("gsm_baudrate", "115200") or "115200") + # Strictly default to 9600 + raw_baud = self.get_config("gsm_baudrate", "9600") + baudrate = int(raw_baud if raw_baud else "9600") return port, baudrate def set_connection_settings(self, port: str, baudrate: int): @@ -212,6 +231,22 @@ class Database: conn.execute("DELETE FROM secure_events WHERE phone = ?", (phone,)) conn.commit() + def rename_contact_phone(self, old_phone: str, new_phone: str): + with self._connect() as conn: + conn.execute("UPDATE contacts SET phone = ? WHERE phone = ?", (new_phone, old_phone)) + + def migrate_messages_phone(self, old_phone: str, new_phone: str): + with self._connect() as conn: + conn.execute("UPDATE messages SET phone = ? WHERE phone = ?", (new_phone, old_phone)) + + def migrate_fragments_phone(self, old_phone: str, new_phone: str): + with self._connect() as conn: + conn.execute("UPDATE packet_fragments SET phone = ? WHERE phone = ?", (new_phone, old_phone)) + + def migrate_events_phone(self, old_phone: str, new_phone: str): + with self._connect() as conn: + conn.execute("UPDATE secure_events SET phone = ? WHERE phone = ?", (new_phone, old_phone)) + def list_contact_rows(self) -> list[sqlite3.Row]: with self._connect() as conn: cursor = conn.cursor() @@ -241,6 +276,7 @@ class Database: secure_state: Optional[str] = None, peer_public_key_enc: Optional[str] = None, peer_fingerprint: Optional[str] = None, + symmetric_key_enc: Optional[str] = None, last_secure_at: Optional[str] = None, ): updates = [] @@ -257,6 +293,9 @@ class Database: if peer_fingerprint is not None: updates.append("peer_fingerprint = ?") values.append(peer_fingerprint) + if symmetric_key_enc is not None: + updates.append("symmetric_key_enc = ?") + values.append(symmetric_key_enc) if last_secure_at is not None: updates.append("last_secure_at = ?") values.append(last_secure_at) @@ -299,6 +338,20 @@ class Database: ) conn.commit() + def update_message_body(self, message_id: int, body_enc: str, transport_state: str): + with self._connect() as conn: + conn.execute( + "UPDATE messages SET body_enc = ?, transport_state = ? WHERE id = ?", + (body_enc, transport_state, message_id), + ) + conn.commit() + + def get_message_row(self, message_id: int) -> Optional[sqlite3.Row]: + with self._connect() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM messages WHERE id = ?", (message_id,)) + return cursor.fetchone() + def list_message_rows(self, phone: str) -> list[sqlite3.Row]: with self._connect() as conn: cursor = conn.cursor() @@ -400,7 +453,7 @@ class Database: def rotate_encrypted_payloads(self, old_cipher: StorageCipher, new_cipher: StorageCipher): table_map = { - "contacts": ("phone", ["name_enc", "peer_public_key_enc"]), + "contacts": ("phone", ["name_enc", "peer_public_key_enc", "symmetric_key_enc"]), "messages": ("id", ["body_enc", "metadata_enc"]), "secure_events": ("id", ["details_enc"]), "identity": ("id", ["private_key_enc", "public_key_enc"]), diff --git a/secure_sms/infrastructure/gsm.py b/secure_sms/infrastructure/gsm.py index 0d807bf..0b059f1 100644 --- a/secure_sms/infrastructure/gsm.py +++ b/secure_sms/infrastructure/gsm.py @@ -7,19 +7,67 @@ from typing import Callable, Optional import serial +_TERMINAL_STATUS_RE = re.compile(r"(?:^|\r?\n)(OK|ERROR)\r?\n?\s*$", re.DOTALL) +_PROTOCOL_ALLOWED_RE = re.compile(r"[^A-Za-z0-9@:\|\-_=+/]") + + +def _has_non_ascii(text: str) -> bool: + """Check if text contains non-ASCII characters.""" + try: + text.encode("ascii") + return False + except UnicodeEncodeError: + return True + + +def _to_ucs2_hex(text: str) -> str: + """Encode text as UCS2 hex using utf-16-be.""" + return text.encode("utf-16-be").hex().upper() + + +def _decode_ucs2_hex(hex_str: str) -> str: + """Decode UCS2 hex text and ignore junk after the valid prefix.""" + try: + match = re.match(r"^([0-9A-Fa-f]{4})+", hex_str) + if not match: + return hex_str + valid_hex = match.group(0) + return bytes.fromhex(valid_hex).decode("utf-16-be") + except Exception: + return hex_str + + +def _has_terminal_status(text: str) -> bool: + """Only treat OK/ERROR as complete when it is the final modem status line.""" + return bool(_TERMINAL_STATUS_RE.search(text)) + + +def _sanitize_protocol_body(text: str) -> str: + """Remove control bytes and modem junk from protocol SMS bodies.""" + no_controls = re.sub(r"[\x00-\x1F\x7F]", "", text) + compact = "".join(no_controls.split()) + if compact.startswith(("@S:", "@G:")): + return _PROTOCOL_ALLOWED_RE.sub("", compact) + return text + + class IMessageGateway(abc.ABC): @property @abc.abstractmethod - def is_connected(self) -> bool: pass + def is_connected(self) -> bool: + pass @abc.abstractmethod - def connect(self) -> bool: pass + def connect(self) -> bool: + pass @abc.abstractmethod - def disconnect(self) -> None: pass + def disconnect(self) -> None: + pass @abc.abstractmethod - def send_frames(self, phone: str, frames: list[str]) -> bool: pass + def send_frames(self, phone: str, frames: list[str]) -> bool: + pass class GSMGateway(IMessageGateway): @@ -35,7 +83,12 @@ class GSMGateway(IMessageGateway): self.serial_conn = None self.is_running = False self.read_thread = None + self.poll_thread = None self.lock = threading.Lock() + self.buffer_lock = threading.Lock() + self._unread_buffer: bytes = b"" + self._processing_lock = threading.Lock() + self._processing_indexes: set[int] = set() @property def is_connected(self) -> bool: @@ -43,19 +96,38 @@ class GSMGateway(IMessageGateway): def connect(self) -> bool: try: + print(f"[GSM] Connecting to port={self.port} baudrate={self.baudrate}") self.serial_conn = serial.Serial( port=self.port, baudrate=self.baudrate, - timeout=1, + timeout=0.1, + xonxoff=False, + rtscts=False, + dsrdtr=False, ) self.is_running = True - self.send_at_cmd("AT") - self.send_at_cmd("AT+CMGF=1") - self.send_at_cmd('AT+CNMI=2,1,0,0,0') + + self._send_raw("AT\r\n") + time.sleep(0.5) + self._send_raw("ATE0\r\n") + time.sleep(0.5) + self._send_raw("AT+CMGF=1\r\n") + time.sleep(0.5) + self._send_raw('AT+CPMS="ME","ME","ME"\r\n') + time.sleep(0.5) + self._send_raw("AT+CNMI=2,1,0,0,0\r\n") + time.sleep(0.5) + self._send_raw("AT+IFC=0,0\r\n") + time.sleep(0.5) + self.read_thread = threading.Thread(target=self._read_loop, daemon=True) self.read_thread.start() + + self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True) + self.poll_thread.start() return True - except Exception: + except Exception as exc: + print(f"[GSM] Connection failed: {exc}") self.serial_conn = None self.is_running = False return False @@ -67,24 +139,44 @@ class GSMGateway(IMessageGateway): if self.read_thread: self.read_thread.join(timeout=1.5) - def send_at_cmd(self, command: str, expected_response: str = "OK", timeout: float = 2.0) -> tuple[bool, list[str]]: + def _send_raw(self, cmd: str): + if self.serial_conn and self.serial_conn.is_open: + self.serial_conn.write(cmd.encode("utf-8")) + + def _send_at_simple(self, command: str, wait: float = 2.0) -> str: + """Send an AT command and wait for a complete modem response.""" with self.lock: if not self.is_connected: - return False, ["offline"] - self.serial_conn.reset_input_buffer() - self.serial_conn.write((command + "\r\n").encode("ascii")) - start = time.time() - lines = [] - while time.time() - start < timeout: - if self.serial_conn.in_waiting: - line = self.serial_conn.readline().decode("ascii", errors="ignore").strip() - if line: - lines.append(line) - if expected_response in line or "ERROR" in line: - break - else: - time.sleep(0.05) - return expected_response in "\n".join(lines), lines + return "" + + with self.buffer_lock: + self._unread_buffer = b"" + + print(f"[GSM] CMD: {command}") + self.serial_conn.write((command + "\r\n").encode("ascii", errors="ignore")) + + start_time = time.time() + while (time.time() - start_time) < (wait + 5): + time.sleep(0.1) + with self.buffer_lock: + text = self._unread_buffer.decode("ascii", errors="replace") + if _has_terminal_status(text): + break + + with self.buffer_lock: + final_resp = self._unread_buffer.decode("utf-8", errors="replace") + self._unread_buffer = b"" + return final_resp + + def send_at_cmd( + self, + command: str, + expected_response: str = "OK", + timeout: float = 2.0, + ) -> tuple[bool, list[str]]: + resp = self._send_at_simple(command, wait=timeout) + lines = [line.strip() for line in resp.split("\n") if line.strip()] + return _has_terminal_status(resp) and expected_response in resp, lines def send_frames(self, phone: str, frames: list[str]) -> bool: if not self.is_connected: @@ -92,89 +184,160 @@ class GSMGateway(IMessageGateway): for frame in frames: if not self._send_single_sms(phone, frame): return False - time.sleep(0.8) + time.sleep(1.0) return True def _send_single_sms(self, phone: str, body: str) -> bool: with self.lock: try: - print(f"[GSM] Sending SMS to {phone}, payload_len={len(body)}") - self.serial_conn.reset_input_buffer() - self.serial_conn.write(f'AT+CMGS="{phone}"\r\n'.encode("ascii")) - start = time.time() - prompt_ready = False - while time.time() - start < 5.0: - if self.serial_conn.in_waiting: - char = self.serial_conn.read().decode("ascii", errors="ignore") - if char == ">": - prompt_ready = True - print("[GSM] Received '>' prompt.") - break - time.sleep(0.05) - if not prompt_ready: - print("[GSM] Error: Did not receive '>' prompt in time. Canceling.") - self.serial_conn.write(chr(27).encode("ascii")) - return False - self.serial_conn.write(body.encode("ascii") + chr(26).encode("ascii")) - start = time.time() - while time.time() - start < 45.0: - if self.serial_conn.in_waiting: - line = self.serial_conn.readline().decode("ascii", errors="ignore").strip() - if line: - print(f"[GSM] Modem response: {line}") - if "OK" in line: - print("[GSM] SMS sent successfully.") - return True - if "ERROR" in line: - print("[GSM] SMS encountered an ERROR.") - return False - else: - time.sleep(0.1) - print("[GSM] SMS Send Timed Out waiting for OK/ERROR (45s).") - return False - except Exception as e: - print(f"[GSM] Exception during SMS transmission: {e}") - return False + needs_ucs2 = _has_non_ascii(body) + print(f"[GSM] Sending SMS to {phone}, ucs2={needs_ucs2}") + self.serial_conn.write(b'AT+CSCS="UCS2"\r\n' if needs_ucs2 else b'AT+CSCS="GSM"\r\n') + time.sleep(1) + + self.serial_conn.write(b'AT+CSMP=17,167,0,8\r\n' if needs_ucs2 else b'AT+CSMP=17,167,0,0\r\n') + time.sleep(1) + + self.serial_conn.write(b"AT+CMGF=1\r\n") + time.sleep(1) + + target = _to_ucs2_hex(phone) if needs_ucs2 else phone + self.serial_conn.write(f'AT+CMGS="{target}"\r\n'.encode("utf-8")) + time.sleep(1.5) + + payload = _to_ucs2_hex(body) if needs_ucs2 else body + self.serial_conn.write(payload.encode("utf-8")) + time.sleep(0.5) + self.serial_conn.write(bytes([26])) + + time.sleep(8) + with self.buffer_lock: + resp = self._unread_buffer.decode("utf-8", errors="replace") + self._unread_buffer = b"" + + if _has_terminal_status(resp) and "OK" in resp: + print("[GSM] SMS sent successfully.") + return True + + print(f"[GSM] SMS fail or uncertain. Response: {repr(resp)}") + return False + except Exception as exc: + print(f"[GSM] Exception during SMS: {exc}") + return False def _read_loop(self): + """Continuously read serial and react to unsolicited +CMTI notifications.""" while self.is_running: try: - if self.is_connected and self.serial_conn.in_waiting and self.lock.acquire(blocking=False): - try: - line = self.serial_conn.readline().decode("ascii", errors="ignore").strip() - if line.startswith("+CMTI:"): - match = re.search(r'\+CMTI:\s*".*?",(\d+)', line) - if match: - index = int(match.group(1)) - threading.Thread( - target=self._process_incoming_sms, - args=(index,), - daemon=True, - ).start() - finally: - self.lock.release() - except Exception: - pass + if self.is_connected and self.serial_conn.in_waiting > 0: + data = self.serial_conn.read(self.serial_conn.in_waiting) + if data: + with self.buffer_lock: + self._unread_buffer += data + + try: + text = data.decode("ascii", errors="replace") + if "+CMTI:" in text: + match = re.search(r'\+CMTI:\s*".*?",(\d+)', text) + if match: + index = int(match.group(1)) + print(f"[GSM] New SMS notification at index {index}") + threading.Thread( + target=self._process_incoming_sms, + args=(index,), + daemon=True, + ).start() + except Exception: + pass + except Exception as exc: + if self.is_running: + print(f"[GSM] Read error: {exc}") time.sleep(0.1) - def _process_incoming_sms(self, index: int): - time.sleep(0.7) - ok, lines = self.send_at_cmd(f"AT+CMGR={index}", expected_response="OK", timeout=3) - if not ok: - return - sender = "ناشناس" - body_lines = [] - reading_body = False - for line in lines: - if line.startswith("+CMGR:"): - parts = line.split(",") - if len(parts) >= 2: - sender = parts[1].strip('"') - reading_body = True + def _poll_loop(self): + """Fail-safe polling in case +CMTI notifications are missed.""" + while self.is_running: + time.sleep(60) + if not self.is_connected: continue - if reading_body and line not in {"OK", "ERROR"}: - body_lines.append(line) - if self.message_callback: - self.message_callback(sender, "\n".join(body_lines)) - self.send_at_cmd(f"AT+CMGD={index}") + + try: + success, lines = self.send_at_cmd('AT+CMGL="ALL"', timeout=5) + if success: + for line in lines: + if line.startswith("+CMGL:"): + parts = line.split(",") + if len(parts) >= 1: + try: + index_str = parts[0].split(":")[1].strip() + index = int(index_str) + print(f"[GSM] Found message during polling at index {index}") + self._process_incoming_sms(index) + except Exception: + pass + except Exception as exc: + print(f"[GSM] Polling error: {exc}") + + def _process_incoming_sms(self, index: int): + """Read, decode, sanitize, and dispatch a message from one modem slot.""" + with self._processing_lock: + if index in self._processing_indexes: + return + self._processing_indexes.add(index) + + try: + time.sleep(0.25) + resp = self._send_at_simple(f"AT+CMGR={index}", wait=4) + if not _has_terminal_status(resp): + return + + sender = "unknown" + body_lines = [] + reading_body = False + + for line in resp.split("\n"): + line = line.strip() + if line.startswith("+CMGR:"): + parts = line.split(",") + if len(parts) >= 2: + sender = parts[1].strip('"') + if sender.startswith("00") and len(sender) >= 8: + sender = _decode_ucs2_hex(sender) + reading_body = True + continue + + if reading_body: + if line == "OK": + break + if line: + body_lines.append(line) + + full_body = "\n".join(body_lines) + clean_body = "".join(full_body.split()) + + if clean_body.startswith(("@S:", "@G:")): + print(f"[GSM-Debug] Raw Payload Hex: {full_body.encode('utf-8', errors='replace').hex()[:100]}...") + + sanitized_body = _sanitize_protocol_body(full_body) + if sanitized_body != full_body: + print("[GSM] Sanitized protocol body to remove modem control artifacts.") + full_body = sanitized_body + clean_body = "".join(full_body.split()) + + if not clean_body.startswith(("@S:", "@G:")): + if len(clean_body) >= 4 and re.fullmatch(r"[0-9A-Fa-f]+", clean_body): + trimmed = clean_body[: (len(clean_body) // 4) * 4] + if trimmed: + decoded = _decode_ucs2_hex(trimmed) + if decoded and decoded != trimmed: + full_body = decoded + + print(f"[GSM] Message from {sender}: {full_body[:50]}...") + if self.message_callback: + self.message_callback(sender, full_body) + + self._send_at_simple(f"AT+CMGD={index}", wait=1) + finally: + with self._processing_lock: + self._processing_indexes.discard(index) diff --git a/secure_sms/ui/__pycache__/main_window.cpython-312.pyc b/secure_sms/ui/__pycache__/main_window.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9e77fdb8d6b5d71d6f56d2fc353e28ae5dae0a0 GIT binary patch literal 120885 zcmd?Sdt6+{l_%aWprM=h`za6z(SQ&jgx(LHk|hh-0!cQK-9p?3A@PuJH+m41*okbT zL>Ws-W`$>xF-bgQWRn;>&WtjfUm{POiRQDJ-M*rgZe26G&a8JbOm^p|jObd=>eQ)Ir~Xr7VuA@z#<3p{w2YWd|D8URCr-B9by`iP zD<;mwnFmeX(%al^#_cAW0yO_)EF5&XJ zmvi~urCdRG8QfQROvfKq^3&d2;fV^9iMMb?!)9(-_sS)Osz6=M$M1v6dDtc0ogS24G0X0C#1<7&QY?XE)n)!#68 zS97)JP2H<3rX41(?!1Ysf6XG5rn`o@t$|yErv`2Fq*6-l{hCbR0xzyxIEHm+T^kp-8thw9yxapGl;$x>xXRME#PMclZ7AR7O z%PiWvdI#HheuO9c-jlw8k>Q>}*pr7x_@SP`fiaK!Nz$t)HCL2^&IaxFk0N%GwdBpK6H4rwz0N$6}{TqpKv9NQa^k54e9l!^!kqUdP{nJ zS9<-a^!hXDbxnGGPkPO|oXirE0)?a~q1Tw>?axZDXQUv1F1@6fx1X23zAe4JCB0_Y zOMK4dkiMmYgydpflpLqo>-Kr+>lNwss^s{b^m>-P#H-TlbyqyIgru^Eq?&}JI)@|( zgl456ud~4+$av?ZwxMZ~q|lig?u(D_ZO7*L;`+*U&4spA%odeg55xq3^sa z7wzJQY4ExIOz4%+cWBAQRS;ze_h zX!eR`PBiz4=6=z9Of(-C%_l_jpvx|r$3*iP(cCMVPl@KEqIp;}52+Pu^$dD^9#<@% zf)X=VR_r&ZGWX2Y_u`6K(Tn+X@{fnnHth58Qn~M$tL~Xs-7~MgXWlNF-7L&K^TvDT zihE{McALw}Z$u0}m5dEAT-Fcmc!j1z-wC~X`#GM##Ah%M(bCr=T0FyI-0WT(>dyO@167B8t?#Q38gSDw1yVlu_x zN9gMA42&x#pXxVm^2tcoWKz&CZy7gv@#{4CWF%`cJ(KUFJlXS1rjKgGp5qS1roG2a z%2Xn!qDAC-eW$DN8zdu#EO_?@P!<_=e!m@wcS820*lhI>7t)$8NMTV z_jr%>ob>QK6;5>Q>w4JzNK;o=+rADl*6Zovdyk26au{H~m{UDKb6%=^@x20oXq5N3 zy+hvBqXVmldIpBwrw4|)k<&FN&x&!Q18$$^jE_HvzvU|`AjgoM#!$29-YfxJLhQh z+Zyj8DN`>Q%A>TwU7|v+m^>yTYq~8SD`!4$dQA@1Z98jmS;a&*s@a9I?HM>TFdWv* z)Lg8Y|9~Z^bTgWOkQDv~dHyQXAkS_QdHPzTYb* zsB;?UCBPLI^9S$}Q{AeETM9b1EDTmPivmzQZ$FHuP48#sPL_Z9@I*q$k$S0Ha4efI zshG~2Z5Ldd=1MkC?7DPHa2ACeg;RNgqiTLt!)&*I`#xc9=iI8UiCz9|m*A`nIkG1= z3XT=?D{H35u2l=Gcg(HaiJ0Spvn-UH>$m6fk0GxPSBz+PyN7#*JZ`s`;C2sqAjrI)6K2G#_-$3t>$9HUmu^fj->rnt^SUlKVd_KcpSO2gIV-bU&c}D#CQN6{-7(a1q8+{OVQQnW zo<~DG8Na*DFn-^xM==+y)9 z%3Px)M8LRP4C6N#)Aqy*ae_T(vdnL9nkow}UoR|Qe=Tcn`Sv+`6N6u`GCmQ(L{%lM zcefRWaaB1B4YC3AQmP9aXYCM^c+XR#13*W-hvR!r^VBD=TKPe|#Q5Hk;l6=>AS=20 z{0N-+Q8JE@@f3`RYM+MFSejaGH3g!0@2-SF^-j*Xa9T(%7Lt2rll;j&*UN& z`5$-BH6MYi-`>Njf7RT6HI^rk@f;Z*G6=9dWdnpH!M@isIOuj?HB03oEW2H5g8>Rk z2KC{)nJ|8H-t>XR9Mf$6b*wq&sQFhWGNrCc;Xa|R8jt!nkNSKg;aF1bs{{;cz;VlQ zbwY%b{BUN@qD+ZqlZGu>f5rqT_h(8i;#u6EU1;LO-VuHnXpZmU21dOLG@WBOC3rnP z&ePX3I_P7qEy0xl#nU<>qFGsQre>9@rFQ2N6torwnl)rk3fgl8d+y}w>H2`Z=Kps0 z=6zI|Q)HZ>Vup`T)=|^78mT>%_3^V5i*P1CM#iVeI0u8x;d1YKnjFcXHJVrLT^8zH z_Q>8v;f#A%J$#}dLK|l}Z|=5nF$^JEfe>K>LW~7MjAaNB1R{Sn3?U{WIff9w1l;la zW=eS}-7HEN>tZU+94$SYMzv|(H%huOU~$N}4P9LA$`@=GOkYF?r7nKKWJu#hplqa1 zHI8^VgL0p<##5TKj)-`U6U`j)j9ck%CQexvqm^YvL*tGWr6KD73DBmDpjLQ|T zOC?VUt-XB(ik(ZCaVQW{-ZReEs5BL0%NWG-bURY}QA zFe%l-C^jyQ^||!(b}nNkQ-cCRuTa8hZ-r8%DYVq&qooFWz(lZ`O^=%{zNE4avO2`L zk$`d@7buS}!T7~r z|CFBpx$PHy?D@xSzkn;A_qOo_%d4QBc+cSJ0nT?!jEC)*N8(_>dPcOtJS)axp>S-3 zzvtY%+jDjwiq_-xYz-To)aPk+cee}%O#v6oMpJ+S&rX>jF=MXto*nL0*e6#ltW0b^ zmPk>F>bPg$JlHdIly#^!5yTx56R?dX7p$^8v8;bZfABt5Cgl zu6p~^GV0q3pQ~ok+QV_KbpAZPMEgS>kL>SqH}4Q*oA>T(ZQBGd$ikZA4ooe=FUT_+eFC)&E&9_te0@ZF9k6XVoI zxejg-)=B)!RIC_@7C8yKM2Ojs%AH7}0%)KdF`&l6K|(WxHy3m8KkKJ2*VMs)eU&zy zRla;IU|+3$*UXd$>~-4rgQmNi_NMpJbFXZ?ym9jQOvPOKnhE~%g+wrehR_Xjc7WeF3ufIa_?-4ve~wC4-<{Gh!=u$N4)xmJ3;^@i7f=t;l5Bw%-k z?9LbBFT`J}0f~9NcOpJu-vy_jJx{Rb-Le<_=7V%oO71_I%<-wWohf&$c;EBVSpExp zQR_z2I~(KMVy!=pF~bZaObOOj{O)cBBr)BXpTYWVI2lAJz9S!{YoEhH|2V~pcsR2{ zlb^GWTlh`mRz7Lmsx9C(8fOgo4ku`nhvsw6hO|{yftG>cgn~(y=J*+_k}n~#C}p!L zVa}Mn7H~&n&c!S}CtXQnlyt&#b;V+R>OD4c`ktkD9P}(5F(*PUG z((s2K^ANuDJ`V!}CM(aMReph+Zhd{{)t#^Hny`j4a;`jd`Ju_axr`MP@wXjGx7Tc* z-0-q@dd)Y_+{|D7&dM95KdPR17@a!kTqZb|1)W&Ex~9jkal-0mzteTg+45dq(Mzru zUH-Dhxx95Z^R`^8pV%D{e&+P-r{-3-u<)(-Stb!~h*&0}U(kw>1AAks%cOHwn#{qs zNI^@hgbI?DAEiHoEV42VgS0+kt=5k)m~?0{#Ttk$qZs~j$@oiMONmyni!{_ljUl0Q z7Ll!}B-EwSsb5B_-fb{|yyuhBFZ2sZ%Y>vO)1USy9l4$nY&s}39rQo;*u4o%@ z5V8cvN(Rg#uoME$;!twhM1n--(z=(?x~@3G1sC-RsSe|0i=>6ANAP5L=|^-BTWO`o z6yB}0$F!M$onebf|CI@blpO`_#ZP?{OmKHIoT8A!F%mf}6T|lI7@07(fiR9`gmD}r zj1$mxiBENO zTEfUGqqv5oEoYmFQSt>@suHiqRkBMfFv!waAF*iI!`Y(LC{CA(0dcD%Z0$4gO5Vzw zk*b74@PIH?%M4P{fW$psD23FUoD+XUnkLL7hY_aB^r0j>m%`dV6@+QpOnMjuiA;-l zw9&~3Ql6$)&@ z_EwC9tgn!xN>lPv^f^h!i!grisf=&$`1#md+xR~ur?0~Z>z^NwIOM-ciTyDQJtfHh z2^_{&Xd~f1WZynhelpTqfQ zuBq~yc2{T-nPrp6tUVX@1f8XVvvlgr^$PzJ9>23R;Oqk~e1Z9_6r3xk6R(~2J68sr zJ3a#S6-v+kAkL-`UUj~55$4lTFzPa#voh*QrHo|iX^AOv)|s%Fu9%K{ z*1DS7d$FEjZdF|`{~u8-{`)X6kFqYPQago?5biv2wkqhc{5MLtGU2(>C_)vzJjG(IvO_E}^n&?+{GR=EKe-VZ~x z0^q=6M5~Cg(n6d6Yx;Bve^Tvq!_A~xP%K!jBwoOZrckgKP7Y1)pKS}+H>wMkfW1JY zUwZYBCYaIp-JLKPr16%X3q%0|*NmI7T9ErF))|Jo13GAdvST{~=x!2#IJT9mC*=re z07;$vuGayCa2vX$LeSmDXBa%B#Qq%4-ZB^pJqLIkm^v_*RLwvkta-l3^xtDKL|`RG7(J z8q5?f9cC(*0W*!ugqhA|c{2JeT=sbzgxqrAl7)P8k#9Dahp!wiA7(CB05gv(gqhD3 z!7SjG!7Sv8VHR;EFqd)5VHR_xFiW^Hn9I2pFiW{|m}T5bm@BvnnB|-c=1Q&-W(8LT z)5TT8tmIa~tm0~5R&%Riu43Y4H7HRXO0=4*hg&VT24)?^%IZ<>wJ7%*t`RN`2-V#5HLaN)D=d<VZ(&_5JSri zutlI{FoOs!>rgKCFx@H7ayu1YU%q1FVlklbOo6kXz`_ot zPgf!#T#jPb-h8QXe8f~Cju3_=cdNTC!-WWAP(lcx#zO!#;V^nhc#2wF#9XD7N3r=< zD>lxdFyfRqt8<;=uf35{h4!V`{?NoZ;Zv!6YwtfaeND$uM3^<2pJFpk(>Oh))mi!0 zDk*4hkbf#c!@{6n~u26A2V(S;-Z&FK1<3s6LH%kV@8&lVIZ|nvac0N9K_$F*tK^UtoCZ| zijB)cyhoHT?X9)JS(7WfgC~-g+gf=V>pTsch4--T zTi6a`07A+n&i6jK76XC8{&y6N97YMr-2MSXBVW1w?B70pF$6(GlCUhMZ+d+fvV*Vi z)N#T6_yS&Iag{BjycZjFwqeNh-b>gli36;v+sbc+KjRvVCCjdlfTQV!*O+5-bKR;v zBP1KVbu3l(mAA*>zfg~;E|!1<#=o-5sSgs)9aJOiA5)@1E)L? z&pA2j6SI{-Cp>45j`Z*x_KnVtz$4$Sx(tvkCB+&UcK44W45Xfjks&4F#hw#Oi+DcR zw)2XX5w95M89p_@j|@wU_kvv*_&5|utF52qv+D5sSuf64J;d;>nf=OADw z+WS2tkk;ZMJ~)KM6}CEQhZ1ihtR*f@LUU8g?j8H~?(b+76Gqr}94+bqgQKGZgPhxY z3_>)boi^A2fk83NeXM7ggK${?;K{_0XVJyb<^r01zvS*m;CMTn{i^321 zCG|$O8YQYhU3v}SV=Plop{VrzU%|_}48w(%U$S6Uz@8V%E1R%gOb)GZ(H`6KP(d-h zb3-eu=v@}7s-<^DsGvx4FJk`r^LYg?RlZpHQq7Ase%IFP*15da8&)B2H>EXSux!dY z)fHT^Nm#LIZpD_lf~_p>d|~la`VQ#D;T-4$H5O<1+fU%lO*)fBKdQy}LB=cTS-X1S1AK5YwDY!oUs2JD;2 zC+R}crO`S0!ip`|*O2=THG*rpAy~CjsM>j>mpt}` zik1_&@MFD#;MFJ2m)nD=jvm1o$hFkWv(((_uRegxQWOVJnzykK0 zA=@ns|D%^hK3?-#91=oYm6Fv$=vj7bt~CmSZ7Tg-0VxcX3I?;ghQ%{r2_eS>EhohW zx(19ABUx$q#C%Q1vU?nxr78YwGgWY zav9(}KyssAj~l2PGYbC!@)i?%d_I2Q=%^2SVFXhC-^d`i^8W`0rlAqMBtdG4!nzjX; ztCX)j(+#(rwZEB9&ZhlwDHK@8lU>2W8lkYpU$A=mso9KRU5ilH;;(JJ-s;bL=x5Gd zw28jke9udi`b%X^Hq+a-hNex{w>O$$c7!dDYzV@-(G)Di#g+D$$4%!f=onEX1!q(~ zEE?Eww@T@Ft)?eHt}|Yjx*$SE@-wa@Rxf7SBrzVfo2t7d^=eaFJNUmxTnM+vN(~79 z>}fdg#QztQq#HIuCqak6AZdeB)YI18r^_??9TXZ4*}Q&wRxvr5RUn#){8O;|P2 z##p;-Q=sjZv+`GHs;b|75M#`NpGa#mo8C6ZHziu%cA8;^wH*yC)^<%$oMP(BTV8vQKnTe_af+6UO1d2I!5v{ zQBeqhe^@Lcj9zkSbQh8E!$R-JDkHg!ed0ysrJ*c4A2 zHya6YD4h-`Iv{qB*wncr9wRokPsLCG6Y{1zB5Gs4cx*Vet8YtUMZ7*CN3-@5taONPuTkccI1tyILBFp!#HAy!(-y z2a@7a_Zuq@?|0F{St-%KMxq#|IO8DG27;q}x-nR_RjAq;sM?O5yNNy6O$(-%3+d&- z^hzPUa%Ro+sn<5mrLUW?haBmC$FixN!LkiP*@i&brciqR#NPQd9G#e|m~Nd*tDA@m zC1v`PfX=ff%Yu1T0{)Y$X5)gjtwL>Upte1fU34ial${^Ut`xE>rL!?J-r04pjn8Fo zp4b^m&h{slPTPYOTZD=&fr@RR>}8jd=d%hY`=`##X3S+ZPVBgoXiCl!l1lC*nNl*O zVWaPi>ad}8HVqrr;j+--EbPRr`K%rnV8UM1m|177IHS_V7XxDLeBPpVqJu~vpn0Qq z%A|Dyuz?#33eMy0#ePoDuy1U6cpDjoNvHOqWgbJLdO%j(OkNINdY@$W+QZ>`p1k@2o30kx6j<}q|_<1w81tB^}KngPenn-OW;3}2O4f`k0%gcjA=jKU+d5ecS-K}URW zimHkLR)Scg&P~KdpIJFOz{G}RbVXg(Fa~m#W5k`0504QxtJ;;d2Jc=%S;0f$3A=afz`K(Z;=GUMlWyo;F=?4YocZv_9c~lACMwJRK?T!!ICMSXF68 zV^t-(7KcfqR+ckQSSHNaz_OfIy9T3c7ie|ai=X<0gUCPwU~+_Hp2|NGg6crA-?YfEXlqf`>tEORD5~`x{)B2NLZ|}On2Rg^+ zT9q*ixn^1!Noygt3@W7FL1Ktv+_4@nt+$zYA_A(5Zlh^IbTB5lgsHtWksl!fN+Y2R z&~hXRWe}u{U=|AlSm(XWyiis_D0ex`U&Y6)!1)gOWMw&yb=+~9s_V&xPI`Q`Wh(X$ zcMIS^XXOR6Duk?x>8!b|+6hOfwowg0asIXTK)J_%V%Trb3D`$8 zkAiCl0xLhw#?wv{gDu6U#1+yow) zbeJXREEk;RQ{y)p5xsE_4bM)?0gK;Rj_<^S76hb|FOyCx#X0!4T6S0_c7F)Zot8Us zCg-ZfObNoX^t<~F824dHn7t2PPefs~YE!lmfu^hiKs*^r*J-iUljIPnL*-12&gZGW ziSd0zQn+#WmfuMIuYj7JFs9OfvfWY$rHiPG?XvL6%{Ua%E#4L+$Cla(uU>Fe&Xx!3 z+l2bIK>ZE?pYcEG)X!w&oPBxFj=oWi6T8frP7Y0#-?FpZ%WvHTVGEvFpkb!tE2V-vW|1a!(DFDfr!w?hI4dG+hrQm5w7n4*s zw(&^2B_^IIGDha1_b|?LWbcvp8pRk!yGu-38Xd}1z!NrK#KVr@LCKnl7Jn)cGA-6t@*n_%YQt%>`?=3pLvVjwZ#UOmLLZ z?$>%@#rkV2epvmz>Kift#rapxfMdTj^tC;$dLg_1mVM2AdLW0AQ4eI?*J>Rr+oGKe zH{$LpEdmZOab(q=HVzFHI<^<_bj=s5p?G5%;DCX&6T z;u0TB4|K3A`)Cha+DK-5#0t|al3f`sTnDwt7O6$jaujyhd~)vO%9qy!OE(Cm8v@B2 z89OmLV@zfb>c=M)lVQ(3SfY(;NHQUN>4`Fm&BtSv5ZYT=O(VWdah27XU=m_|Ie`Fn zkPHSo2W-l6stb1gEX2pbArfBU0s-3Q{{f1;0-2%*7nRbfRb@jpSk^3*HD6!*lg)2$ zo-2D~!gkAEvbe8Nc^O=2&j)ZZ#QD#VoRuD$awX+`Q?pSYq&{n$+89=)=d8M33C{W$ zaGC-|FcfhtlqCH4)yDZ6;YcIa;keC#&TiAn=u&SJ-sRJ^HdwCVO#Z5<2b z%~RdQm{Dk>@q$8SZAmT2npU)eX$PT-lRc88OwaJC96JQT`WMjCI{3i!`jJF(+~SW{-sWZcy97oBu?v<+IU zCe5E815YrR;u2C^!Ibqv%KBhRn~>6Wee`8s#X&^sQ?Yd=B+i8wqVXyA!q9?`!)%eP#Kg*ZA^k{B#_X26$tDyOBIc%0Plg{ zSWS2-9|#31D3A#&C`5s7*(+F~_&u+ag9&DhxB2o97`>t(I;r!c%1<9wQAb%3_Jnj}j+2A`yHlapWUp z1|{>hgq2Lit9&DJF`y_PSTdtH4=5SoY*5%{@4o}|ywRX%KpX%2LXVZq2zn1FnFKwc z9jM|n2VYdA)|^8@sGk3I=^Lg_4U zm7|{@;DCIFMoxM76Xaqe7ey41MxvMqC5u{aVTT08JS+|e`h2p;D3;$E+8bLLjt-&* z97VQXT3<+F7QV^Df#k~BvS5A7&H5Iix^oJGIrTzL{cJ;UO{=h`^*Z;Hk+((dyq|@#X?GPFr`{ZslJs` zbH{3W*gRzRSGM~zSNuE`nj{zH+VzveZyye}9TD1&1lo?Oxt0hiC75Xqa`grGx`n;& z;ND?j?{Hx6NnNfhgp?JxQdT0@N6b&jxq`csmbDmhtLX7B0mNuT;5qCAKl2H2cSxd2 zNBo(6DylI;Zz}prhww=vdQ-%s7tcFq9ZoS0r3_DyohpKXc#4L?Qn6GfF|mk8FPSxN zJ#H|(I4B5H%81m}gA-azE|v~`@OCb4+{)QGQ=VxIxiDEUgVX-UiMtZWfx5Prdp<2C&#eG ze#yqf-$?aP&{-uot7bE<+1|+aJF9Lvw?NJ@$3PFzo6RLH8Kxg)#J3b%f3(aD)0o8h zb7>5l`8X0C3!_qH5(My3pD1LF$}&&`sszG}x;Ge&7psgm=I_u_P;6oTMq^qhSE(DX zx}_Z75QXG07@74(!A1Oxh&vt(X8Jp)VBufJB9krKs}glP)1bW!8LWRy?X`immwN7k8jArvhBS<-K-a;z z5(<%TLE_hHa#k(|AoXUp+8<;9G#*88HNaC4cP%jXF-}F>r06t}0__gAn>b1fsM`~v zH}HG?-HYhe3~+bgx(HIAB1jd4_iS}qVuOvwyLT<(%50Yc!BH?}lMZ|cjyfh#Stg`2 zesH6(qESd+cP&mx-|lyA4<+YI;L%r=SWOH(bexOU&qUnd4<}#o2i45%FXpQbBEKUa zkTa@QPY8d+bu5r<5B~^)GHp2v1QD?1X>HCkFXGel#A;;<u=3?%Tgh9F|}UR-I@`lj~g4Y=>3 zy_WuC{aeUf3Ls$czUHjz0U8fgDVD6dOL}PINL4r z=>?MmGdYqUj%`flzP!_)T0$u1JYL;tIBv-kDE!Dmw3x)PIc^Px^JcC)EY?F zY*x9UZIl9HtGpC6D2+V!P#7+jiwk#g**msUf8rk^V<#EA$hZKopl#MP)vv8<;CI6b zUu_Mo4K3_Cl!mtUHfp=J^{wk$)`4+M(>VWoh{V4DLyT?04JaL5qC~O_O^EdRHvO|~Q%jYa@xXIJJ)S3}9vN|U9foQSkeH=Maqo(>Zs|Cmx(89J($gG<^ zAY^WuXurK~%U5=M_3-pMfBG7~v-h3$pYHZQe$?LrRhhjcPJZa7qgZO}41fA6KaT7q z=S;d@?(!$Q{6?GD)MRXA5)hv^F_|`LbjD7Quq2aV>x5!f&dQoeQod|EZ)a{XI=5JI zBjpxAcE-)qi!9WK#vKudciUn7J_)|vS1cO7RLqFyg1Sf1hkZ3k1B`|5qZT7N2-C?G zvJ<7mkYbp8+`1@uP;L?&J{`jLhXi#;gNP_J- zi;$Gu*&|o=k!VT|_Ma)i2IK(oG8I$Jr}z7v{R$3xH~pyJd6d2m-gFeao8IGh_S{a+ z_uFMM3n_#>)Wh!@04Z`?VjL02T;U`4J`1cMm6SFLP*A&b@mxocZcPiHLAZA{A?L?S1TN*Lp z#?6{P7ej=mvZPg(#3=QDGnq(R$hnwvvD%{EXy3pXCYr_=E>_bFe=cr3?zq7M9_I#P zIeXOlG2JE5@lHJe4-|Q-$5X~t*?yGyb8?yO=i-r%5hH-|pbAGkaep$8(8b0Va(nj624iI@l3DjCg#-iUao0lV?&C zsh;t8`D8)r`FQC6kZB(y$U$P?&c$Eh4nV5%#lW1$*Z@U;23K;2nV z#mQ(QswWl(Um(}d!oV$8rx(a^f%^Pa8`SKMoW_yWk-^cSVVnZNH8&@q9sXnwcNXU^ zW2FTFTd^!qw}r-WToH8D%Ktt-c%lyZ*O6;4e(FO-)TZ?Zs&qG=*0n3B7R_#HZX635 zJgets0L|@3uG%bLte@akN8*YwhRWjE{SIM_G(_v0NDhHX^oua|2L%(?_oEVfj=vzV2#ndw`pIi`{Aa? z_wMiNl=#B0Q!vso&lJju4mDKs4vEE-!M2N}9-wMWk$|SL)RoAfEIOn! zMC|e;o=&!j2`HmmR>na>W!N~v$thX+2Dnd>hD53*HS zjbP;^Zsd_--6Ktw=MW%rAsfLf6A{GwFbp&#qoP*|nJa^tjY4MQT;_V>=N8bR=Ty>p z`o+C;G`#@lqM<}0zxrl=_2qrD%W(-+(ehx?>YGKYU$o5bn@F55S{bNVAFS9cRBXO> zD6p&Bf5LoJIxn5CLX%vozKZ9!48r;TPLho_fD1ni6;X&ZU}lf zGhgVq&@p*-HuYKqZBZS*x%=?jJ^rJ;6CKcWKfj`8+WXBX|H+h;xYLY1rc2Jr-cU+z zFr`LFshJ+96DyAkt&jV=pO|ZX(w|ZjNO6Z6)(0DQ3Jp7Nq|Y_%5mM?d*(a-}#^zIU zLm7pkT()G-UlCef8Y)~7Dy|AGbIs=#yp;c9{>($y_W1Wb7RY@(mNKN~7 zmMJ~^a@l0v)ZuI9>t&b9Zl&$`RUwt;S8-Im#WN^j68haG!6$XKn1Xdt3W%-a4Gf%N|rG`c93lX9^h)yq6#vFlFCw&f*A>4VtkFV zBaSA{PD*DA!~G+<9{gJf8z~gJ2*KY)Any-R45Bu{>}V7mjf};~6ctN)DIggNp9;ZI zVeC^XI7&62_<*yXHk~NWF2S*j`4FpgdQHH&TJfn795u|RLU2|DaIqYVaj6mnt(sK8 z=~99d4~Ds~5S%LlPUx1GB+GG1p?u1F%U-p3@KBdygXizij8XS_Ni3B1Q4Kn?(v?W? z&$tU~V$P(MUANC`(O5C}-RFG<`>aNLLMkI2WGQtN_Kd5876OA6=nOfCO;kz517gTZ zR2tqkJ$Z^9D&t*UoJH2iHiR)CF^Nrt!7W3HrDQ-LB2+x~ZE4uxVWAAjMeHG?(@7zQ zcS7Tc^|%3{s6Zcz9}H{BIXE4$xs;l*oR&#B>rGZ>wM0m%MDHuvmC~?XzS8^ov%Ts9 zC4{n9eeqF6p9yxd7yE9~`IhP8rnCH3DlXbvE6?K~RF&AS#132@>KWu;L3P-6+5**! z50G(#3_lrjWPF~Czk+cu9x-bA26(UUo}H|?WYFizh`K?}lLQKGHiAYjzGt`}`g3SE zTvGnaGP*^dv@cC%fo?77<|I;vhJA7`4xcg!QE9V$Hx-m^)KS-B=uHtFJxmTvewa4U zalr_?fC6V&%J0k}qTWaqA{vcto6I3y<4SQg0M5bBdXHFMUc$N3O-Dt8m zoAcYSbDy-lwbAsW+BDeTv8J`fnBK`OCVOLio5lLhHVf=OHk)DpvBgUEnE19->yML| zdy1Ltsmwhix2@Lt;d(9Ign$9>FP;0gfN-|^mu!OX^JwK0~E}}uZfZm)H3U4 z2#XOzK}myQzx&J$L=LiY7-4m_v`oa_%H)zVjLSqbB?;L(T!wi5fP$&v zTdXt_<_HPT-*;XK!}E}L`Y(hzRXooV{mW;cUwG z-ydh+0?y9Nzw+eeC#QB!_s*^jWNuI}dsQH#CLBG--48tjSqF0}gq(^%P9+o3Tr1?P z4dkqY`<-}GPCXR;a*N zRg>bNH=FHkxu(C^)MRN(F#Tw2lLKErPKa;Ivi>;TLiS8E*|V%<&&_RHYyEM98Du8Xk zhR1IqN{PDC!`7pQw};>r4(qxp3b`Hk%Vi0Cr9wX-q zF+-J`f&L+Lm-PiQUeckAF*C7BDy$|(ahC=IqnJy8%g3&7vF%$g3y29lNs-$uQ75_N@AeXr*&>=ED zPoJM7<0~*AHO9C;EMK;r_gV5{311-Fmne{hpHPVvh1d-ptM*^eBbR z$p%4kAj#1hGWP6a3VExqzF&@gp!os4zdlxK;1`>nwYG{l+M`crDk~>0*>W| zZyoEd2G|eidCNe|fLtXRY^EiqWV(snYbYs>A*Jb)Pfe%#Nq0KwbeHs~YfkUy6~0vc zV)c~ot>gYj9u4H}W196P-S0trfnYC~N}Yz-pWj{(ux~~Jq!eCKPBZ=J>{Afk4A?iQ z&LomQT{4RjKr+Aa{lemx4!?MKy6pRT{?4vI;eI+aoW}}5dTu0b-&0M~J=bi0Qv1C{ z=e1g}ubwquEAiV`Bj5LnO5~fjC8_=S%;Hd9ekeB&`j|d*|^Ch6{aEEep=Q;$wR zbuHt1(+w1;IAHH!=}UKU*9!Jp?0(ky?X}2Qg+g%UMq%Z~cMkcF^aS#bP9%ry**Yk6 zUANrGVvu-*d60@84GQlUW7j7?l$svOEDB|_0>Yk=KcAX$CI52%%MZ;q2U6F4kQ!gY z?glTslfjBhXAXk)<$`_r)Kjz{d%fSkAK+ddupfNij{C@wVnrw|Bb1(rD$gV7oRazM zWx7PKoeHG3#f_3vZnRk;_D3YlH3 zOM?m)A5+8%mIq~4bZL2b8_gcQcIrkiYo~qhmzKY>`RkkMeuEaFw&kZMuygG9`U0h+ z)WXGTy9X<`2$frY>hSk+fkIF8*1ukNV;xJmGincDR{-cwd_)}8_-&3IjA0-eoZBGW z6!F}DcNTM&!Hj+XIEwlLn|9`3^;$RwQgNQMe5|;M zh?^94JlD)6>mYdlbz6%5?kerjttKI7bl)78fi<4of{-uNtsTcL56D?tv1NtjT&%$A z0Xb(!&DrWJRnh<)%M`oz=5mPX&*ds~HkY^LvgXeeD6xFyN{TauugRFw5Jp8Y?4mvJk&@|l&&ir5U5 zHAqR_^!XUQ^N>$p?}~qidh1I|vqQuawcaj$z4bkstMt_>i7dIzs_>a*B7On;uVl_ld)+#yS@U)Fv&ei%F6&LLWKOf0Hu}8xOTpjY= zthj6MCBwt4AL}#quQ{YN4C_n1aE-n-khf6}-lmm9v2hJdC;3`URWy@JZc_ZUw{e;n znT^IZomr=}B1_SrXYo4`)4=_B^m49WQaKIMXs9p&;jdQEdvHJm^ zA0eZ=)C^~-tS%%uNK4Y+0dIvA_N&cO6%3o_ujqu?nSozr+Wrp4ZQ+J z0QrSpzx_NuZch}mobiQ%kP}7w7F|ym`i|uF?c3*JK@3C=P2&xBR)p^|X2Pp-u2he= zDAnTI&xulUEaf*S!MAQtP!(%NTdCeCJIhC@QV1a;ORlRQJXuMRJ$M&X{^x2&X~5ih z>C4md-H8ju_|{k$U@kDu1TiI;tS`7RIb52IHX+Ka2l63T~0k@)9EnO6zTX1A~JS8;D9iO7vssRq-kTCiI33Jc(6v znUeT>1ma2b37izh#UX~1#14831)KS3>*VjxKlCLtPKIv7@|nh@$s|#5;V00$6biD7!UJjA7@o`R6I_t7Igi zv|=LTRPsX-9K_hxrj8wL`$Q+}!b7;VwWr_1KTW|I`%*pT-MP2Di=7+hSp1p%U!b#?4U!<&7zvi+-&O|hbcbe15<2#0wg|mn0K2=IajdVBeZ)0?fpW^);qY~ z$Gpoj5d%@ZjGQamE^iB_*9+CF^4c}K z?|N(?b;o>m5%^wUm?cOIvmL}LE10*Zu;5uraIo$emu)^fKP-4V1&Dm$0@gu=W6StN0K1 zO*rO>F<`OIbWIc4+NrSrK^|B%O+oYg``wJtx_E+1q&0Z*uaAf{)_j9D&gJN0kr@E<(n=Z*!rae*6W{PS~6WoqF~Mk zcFom39&mOuXA-if>tWXmU|N`)N#_B*{LU30K!<*d1=&hkp69Y3 zxn+NJF|)^p;Ym35E@6~t0zyXe_r$i*Z%Km@i4-Cq!Q~h%3fnqb(W!e~}T%&dj1Kr27W?M|lss7fV+77pXePFc87Yy8{_A>~r6J ziEu_eH3Zwm&}~=TvNtYn5TbTigT%&1ZxDctY8~;wm6|i+fvW+B7_O94!~@ra2q%2@ z#p-yT2K(v?F(mOkkw((uG%i{ATKEw467d-E+9DhJBgBN-h9z`Ml?SF326^J7u_TT= zV^?@ZjDu_dYoimmK9yf!Bs-xUEWnx81f2M#R>p%X3q3q-q>lCv|w~xMNs+>(8-R{ri7Q49RvIL_$938NFRn`_Jk!0xuQjV z_LIgD%fedqYoE*wSdE8I^X$CsJCY+>FL`IVBnceign^~Q z7-F+T>6HM^S7SAyBQZfbW$%S7}7pdt<%_&1EWBnB1# zOBf5?a2=o%YRLCr$d$#KhD`}eOtFPZO6$uo5KQH#A%rSK#Duvd%@t`aUAX$ z@`#Db*U4iexB^x6JVuvCBzir#55nUAnB%VYXRqfcM{{W6aL9$k1v^Mzmvj_IAVp0)ExP9 zj(jzdptD|Z*3Z^md(`i&4>-5o--Ajd9qof0lKFbijidg9Px||h`<+-7g`yB$}^=#LFaP8DarAqU2pL_mj|2>{YQ1B1uqqxrPJowl55MaH~RPOXUpyby1b}F zZ>^d21=sBq*6qAeFRa^h!#lU8Gtm7+u-hYadjdnJ{O3M{)#o8|6V{q`67b>9ETLH`jba6_3nT_LE+v@=lC#lR4$O;&ERb^cC07^LD; zDQ4Gvzw?KOzIQ0_$m9MaM*~{`bo3$#=u#+MFIvf39*rWkyw;IEVxyom2U(*$_S-dz zoX#!hV~f|zw2GqN-ELr)kHWxER;~Awl{#nEsSQw%{d49Mgvkmqj~dPT4wNS1VQW%g zY9%# z*o%DkPq78a_5xqQ`&HWiLMYmMtYONuM>kRY$lWm(NZu*aHb`mLl=eEYH+uUy+S&}g zS|jhxvYk|HibuktW# z)<=ihBG}LBb*RCtY9Xt7E~`eBhnv_Df;!lBHxjOj?hR8}lFiv!N(@)5gj7q$f*C4!@5svY<&FJG=^n27i|TJ9_779qXB?lD z`o*5JMU5W^fBU%@4Y?X2Qp7e`IB4o{!A2udKDh)C*HMqbA*KNf(&)TCrNKGCzLMd{ zU}hB80Hq;j;!KjlT#6dg03~tB;V~Zw2ZIvn;h-tkI~O}18?~M|AjZ+bFID&vVV6*^ zD4`MoeO>K+Dsmo=2ob~@Eoa4M zH2LG0UF29Yin)Z|`oNkPq^pSxhT{~b(v!qlA|8e{()H~Nm7Gf`Ta-|dBT4I_aa_5t zUddcnGox{%_Ea!Ob-iE-J#{6HjxN*V1+BM^$3b|_#Z^K?yFo1n2|2K5$z>~0Vjw47 z+0d_4kwzs1HbfpgrmG^xw2Jwo%p(SEj-!U^5kB-;wS*`WX_{D`)DInF#`%+8n9kP~@>$$xnM5)|2K)D`t*0 zN;<%z4=QWJCs0;(dk1CRs+Cc(8J3mWB(kjPc!~nowZ832noEXntPZ~Fabol%eO}E< zs@mJI%#A4XI`n8geAU@BN}1Q=RtOdU`dXD7v{^%&Ntp0yyAncsFS)M7kX7elu6AaF zDiCah8#kgho5Fibl=dTmwTOq?%*0){=*3+Tdv`=Ie8SbB|6+`6o!O?)42&i-j%(1L zxb0jMQZ<0oOtY@%Ya{TtpDD|gdLiG36}$Fc;!J52b1C@dK{YW*H|k7jAGaI!ZNeSl zGo^h=Go^8;=rg6AYgzJa^t+iU?Z4xhvP0_^itTqWQyR5-wUVEn&Vw5Ac*7Pa4D6tJ zai&$b(uiM5tLtdxk21$xtd&5Z92%y9hp-tiO57#X z<|D+6I#U{_!5!4$0*x*(T4SiQCsHyVhbu7-qpU#z!#V|C+@~0=_t^RPa|uZ6sN$}@ zqr!6~=9jP)Yn1p#6z1dO30&z+w>G-8)ov8n{BBpkoK|1O_B)u@jrzHY+rs8`$GF1~ z?&wiC3>!ag!Od8an9ZmkY(zRnV>^Ot8YNUj|3#=JjE~`^=edN`qU1@~{m{%|UH?(% zt8s@$7ld&a)+j9-Wy~yxd!G_UdmFX`!>dv|%sin`I%d;w$;V9NNiB{0&l#wlivJ*} zlc@1@XWY6OQ{@vyscRA3k7;#OY=(6uSnF`-aN$RYi+;9|`yzjJ9j&UbSVl?+yxB-l!<2s(T%0)+>n{>6if6vO(xP07+ znL(Yx29@`O5}Z-WqrDC5OPG6zt#+0S>2ar?Pkknql`m(Kooq+L|t*S?K^*3$ys~PjH-Ku1|`&1PElIah?YFXDA?0W;#L~;67CGQl{-5#w$vFIB{SNG z)5cx&S$Vqx2c+?|V%OeLde!2+aih3Pm_eiD`Z=uxicM<=-6%E2K}N7Jq-lW}RbD(w za$d-Z`PPHHNd+%@EznI1%v zdX{9GNlbb)7D?QG_V#nPC-{G$)JU6{q%(l-DK^sRh=iC3Hx-1*aWheDmQd)sBtpeB z2JlzAt8=S)O`Fe5Ja>NpZ0u6TN{#!i()MYzLIMH6?g$y#D3AFyIs25 z>?92iq(^de1p-0A#Iq!+McOBxh0qog8-uJ^1)N{Q*Q>>~8(fkWP9`8;hD_LnRiW9? zw;}h1h>Es@&r+VOmF}zPKq?lAr88}xybURS0-69^X1d*~=ah$}wS73rjf=1b2ZlXS zlmpa+ALS00CR?oDU>Y(wih6_V8NsG*p{aYW>G0FCdV`$=b&ICnV3a12D4><@#EwU; znKlZkD>C&ZlI{`y=LDM1BLN7r9VVE5iHr;6U|}FzD1!aNBp9ThG;|GWN~jHAUC4*i z0#Ux1S~TC--otdIS9}R8z732NGuip{V2; zhzh=bdqUQJf!P?zo}wJ(oe`}VeeQu{KN@sHa?iR zx?Vn{jVGp*MeExTKZGI^rtRfbHln7PG85x&vmNne?HI5{K76Y2?Us7%q^QQT@c$Fh z#XL9Df1&g%4odY02EFc+q=Dd%5kRj#Lrfhx=^0l0Accv%J~&!mTNE)F8KR5rzS1 z@JmNdNITDmbmr&w4@kQSniN8#82uXLd3F|MCDN2ey^1dQ3UVt(T&)wT8geLsS8soo zA(rPBh|{t(B*A(lQ;9--2_p)5NXUGFmhKCLVnrLN$ADTG*4-Gsxk91XtngW~Q7Q-p zofPtbkls*kn8;c@cS#bpX~x3`d2thid)uF^*iwCYL&Ew6acJ}PLS6r&~@@> zdPCwmA8KuDZraBmC#NAYw1e+YQ&<{vnoeOcRT^g;-*XyQvku`JRY{>p3gz&76#oSn zK=WZEg&XJ@9O)OWeLY7dRUoN79H$$A!?@KfiBfoja(fkqnC3nqdN`<5aRU}mtnxQh^dz}3URka4 zcgfKL`5lVY;o>8Nj*|4S-Jq6{4wrYWqedwsYzSE?+ZN@@2| z+KF^mFD|#0>oG{>&Qu_+p<{xDL%DXmXK>J!BngAZkBma`7=XozxY`J{VAcfb6dJn> zH&M|6Bi)UgC@X@IZ_Z8fqPIYUHEz=F;XJ&Urte^~TcX@r@~1=oJUYUiFKk(ADCK*X_v`%x-ei7t5)@o9=6VQyLF46`eG zSM!R3d6hTwDla$BS|;K{xrM=8*Uenl<)&#KpZUvz`BgXbt8lk&ntZn|shq*xy3G@b zssO*hT<`0Z5z`+5T>ra3ws!aWK>4i98KzQSYlf2U9;Ucpg?DDm^z&QuevvZkLo(}M5{_F=}Ia|rG1x58z2^e z7Q`mA2qAW{gTY`SbOi`&fvXaWEF~nJ#3jemhPczn-N{%^`n5%(v}MOh3L2!Z(h68uLld<$zO&*ZdJMx!glxTcR{D_a_%|jp7lH5^8W~K zx|fhw7Tr%H=cF(17}`O2HeqEbGnGMu;bCQ1qNFcD8PWkA^OEo=*H%*gM*i#hqvD%A z*L&WJ2gJ0$SR`1RLe|)XCKC|CVZ2QbY80Vne8U84sKzg>Hz885R~D{+uU2k3F87@E z7XZK@T4#}xzGzzO92(z8iLJU{T*m7uP}!(dHr^Wa7axnPCK?&4Ra*6}cGx`lh5a*E zR>{KA+Q6a>%AyVO{!{W<$$x3!*BF@aLHH5>d1SNgbR2X%u-T3|S{%6Qw;!jU(pCrM zT*Q(tQiP&_P^JiFqbV@PthxbPi!#5k?Oy4k(Z;~y24!)Bd|&`+G=%ex$V9mr;v z;|OI#Ke&0q@f>9%IlMedAN`b_XW4jIHU#Vw5Xuz+j=#|08*YfQQ0^DD-z!@@x<9aF zqq1bhq^lhsH4 z!ZE!hBfu$IrIf6aH|&>>p-UJ0g%e>9S1To}(eyWqt{2^0P2RxsTd2WuSYjNdDROGjaltDUp%QTVn6@hY!kYVrZ}B>% zyzXW?f!1Sg%sz!lRLJ1y@#JP;ycR=@O;WhIP!SeVh04N0zp#SE3%|t%R8BAjm`_hU zpzzvU2bAS>rw^#ioNJv|J4b2*rL{_F?N9pU!$h zYW-;Ro5|OcZs~K#mAT;B{jh6Hfs~hUw7~MOZ>s<+8BE zFRY&N!TnqquVGRlU$eoKRpzcgQ5#sZ{q~yeZ|#*~CVupUKcx-5Cy$oM42V#CCxiM8_Hr+qHJaF}7N<=rOZefZjCW__*Wb|9uP&*;{- z$dH_kGuMJUuH_%h@S-V}-?f}2uict}y)`WD8}3u@O#{T-BWIL`swZ$b0ahZkNRi?y z;-Cd=4Lf+N>Q=|CO4(I*$90(5q|X?^?>c-R9f+pete@20lE|UR+4KJV3w*v^-xyfG z`}X?XzG%(u0z=2Ef;SN_L&&R_4;~2|Jf$2w6*$&?7LqC?BgR;dQ>9x#w_Nn2Gl~BFPLCT7QhxY zuPYqUF~aN0DR{5J#6mT2PaR$t$Cvn1*N2a*6%#2pYH3Jqxkp}T(rDqasBF#i40rHi zc%B)JVGd`OGWbQjoQe6G9K-;7(I_ylRgIQstIa$meV&;xeQ6%+kH$C1p6bckLw@0D zc3;C>tW`>C<@%lI;_}Hetk$1XDRf#+VB&i(BL@vk%Ti7e)(x$OxPKobb>=$=MMv$d zGi4l3qnjQOJ?9q|+%I47#?jZ0j+gyt>03+R+eXuRIh)o?SX^?&6A)?@p%#=MHVCzT zVT0+x3Po4}ppar&Sm75MO%G`N`GsYSm@{unX=L9KJkxEAo!ABw7w%yaHux3}Xy{qQ zVQ_6>xDU=&h?Q2wfxg;c4UvenWW$P2RkdXuJlE+EA~V8zMPpnLnY5%~tai*F26{;&;`e zugu6X;C{Jg?AEA7O&`oioDb%2;ABp%^tj25jEDniclG7Y}3Q9wA-lAD4E@K)&yG=9RpB5CP? z8~Mjn-J}GiR<}C&vI&!>L^ul5Cr`gnMuQ`MDE>;-6)}*x1bD!Agi2n^=H9H=@)#1n zX7i$0{0N)S=A#zOZ>e#ZcSws`wT^9ItISZ>j@j!P-Z%BqVM2>)4khW;?d|9SZ+S1u z2!w7ndky0}?lv&-S;bQwLoUQXKAepG9_FAbM`c2jKloEpPamWwY7_L& z3*)2C7;lW4sR1=~*``ij75;>Uqu#*MhTBUUbdqaW3=~!=g;hkD7nZzs=G8N!X`^DG za;;LicCv7tFZzyjKL2EjS_FP858q3xT|aFA}qU=FGVqh=_b#iTdj7>I(d9h!^|8f^5hBB5ELMq_Rj zpmOj-ajQwM0`k`pZ%LaCa6lynj&vq+%Myzrv%CtBXllNc_;( z%fM$KG3A|%Jz)vJ5nWGbj}+lc{r8blMCa1_9LY_>rjfU(bA1IF>YZzeKcRBGHc+!o zso55&Ii%Da^4A;&EqQI%)m?$~WlH+8$@FU9reMaxiHwQoZ?D>ZeYL!&MP7JZKHepF z_sZUWrTclgYd}sL^fd;H=Z!pnySV1+)`=XvL)!y_Aq%*cD6S>rwKq1&t|fO|+aAQ( zYLD3E1a&^qM>q5lqv$Pz(SVmRN|mj}Hh!(Y%H%~U2E$5SFBzKFR=mIKl%2=vV7e+4{5Ktmd#HZoXTcX`FmB+2! zGk(pzh$$2ZIeAw5;e}eIlYhaZ>?mL*8D@sBKDBvjXB7Tk$U|FyDDi`8e z_5$tOOy8^Hy7gnWprvUrK`a<_ncBo+3=PB#BtS*Eupi|bfSNaa=&56ssVZCdl0^9J zN*OEGMnB4!cxH+AEyk}{qLq?;g~xcJoz3=zqLaSi%u+3`@vF^_gGr{=OB_f7GOsaT zSoObUS{eZAA!YgL{jWxgVf+G;C&Eh~M(=XtxppQ()n_19E1R*LW2`+%?8GOEEj(t8 zK@|=r4l3DX#qrWd(L~%-H1|~@#k((%hACW%8LCuV z$M1(wsrdr)^`#->!Cx4_^9*0+T$_|e6W3oYrn6sRmA;+;x~8eNwx*^IC!1DPZK|um z$*PUjD;k#Jq;5schK3b5scBkX)vz2V)m5wNnkde)Wepn}YuU-Fx|J(&QoC_O({g&N zscF^9W&C9Mit1{vKg-mj$f>)bU2Cmumx;oC-A8dl9=uh8Hl zo!E!f^orw}ZOB2}-N-|<`|K3PB-k0TN+EAKOw~Hv7Xm7PfgX(|4q5+G{9`cC|j&-qO?E*4feaoNhE#kO&cIageBaow8`%4g2JxhRdpcX#*+>T3E$YK%ZMc)Zk!^9LQz!_E$4d4+T9A5hg8?oc_wZT- zfo-ao{@PDcy1$@e&@}kpF55nK*i#zqA7quuZq9o^^D#Z|zoS0O&VET}W)STWyvkj< zeG7wa3tDAGYxWV9N0jhv4{*P0j@sXLLW)`gV-f}mF|9#0z)&9 zR1L#aF3HOsd$ma#qSGi!;1B4fU(+Rdu9rT0NS&?1&6-=vM2@7~#FVdl z;qH;`m88E!6t4V)ff9;ligkZNxyN^OpX}ipU7=Vt^H73%(gHkCZ2!--jX|T8dxARI4vZ{PsLIR@ zWL7Ad6@ko^O6JPR%+=G}R6ig$)gPo~ac^78l(c1&Y1KaAZgSQ=NIghZw^VVL1~azY z%kzZ0mx9bFF$r$AlGBF7E9XX1MpL2Hw9hB{lQsakb1K@Fy4~T6xnDScRG2KRdT1+) zTQ$_|+jM2cJxv}H@YE=t8q&f#G-&VhCGus7nv0Sa< z;w*eut>1-Qusw_vF;2VnLD9U|245Zg*cO#o4!>c!1zb7nokO=a_;Z?l+k&o45>PO$ ztj39pw@&zW`CWVNyV9>589BvNxV-O`{$kP37x|B!1gy7w@pJxdgutD+fs|Vkn@BAK zA+%4L9ed~p7xZ|qBbZqb$gEZ}t8ZuSlFq-x4qvH*?%j;g9Ke747#v6Ml zcHCO7)bI9fM|C5|=YG;|&dtB=ED*#4uJelPJd*xAyieh0t78|yzt2-HyB#0o75`D$ z$b~lsULUwUzj6G+j|SfwoGjV&Uev9o|CpeZ>{Igg`*uL~f*jx-?C8~_BW2_Hx1NCq zH)ZEBx&18CBSj!2mf7Us4$QX?Z6Dq+(mHx-qQSSF;jO0zT*Zp3cm!hH1s~*>k`(lI z{;JWjx!nW|i3KfKZnD#Fft1cYM3q@2XLoYk0@?#ow*;yH=6Ibt7Zs8sGN_6?eRUhOO1#&8t zoXW|ZWi!N^qIe%|wxi?nRf6WAHvH)aozoh|C<3sE!K`i3AA-w7&ms%O!@-^Z0**(_Ko32qylC5vj1Ocyb}>a@{EOsJYz;76%~pVIvU|jnBKi7XJC@4 zI}r0%iS{v_OMu;kHAlqiy{L%yh*K7ESg{Pn=!o|cB3{DfDV<=Vibk2{DB42I>51uS zfiAR8$wK(`n3RauOx-8qu=?Hs`x%|bV?>Xr1MDj!k58P|9NPiqN{`GF0F&mvU_V1W zGvW|!efEoXDH(mm`ni*k)4`^V0#i2zrpB>@(Wotp9kS&Y&Y^c0TrKJy6+*LgkVfB6 zP*ACf&Yq#*DlYpx2*A7jG5Jyk-niyLnRZ3GeTYOnv5G`4$-GFfUZ9kiemPSucZ1&U zhT^qGs`PTAC;^T>G@2;nq6?Lj>6PB5R2a>Z*n6p~3l2d#+FGEHE)7st+zF`kF3v-O z_D(1kBoD<22~bgQ(S3k2B7)NoB>w3ynSpdQ$>tu?oU#J62<8;sY=;Z-`HCwq+Q)Qy z_OmtE3>Lngfraz6*>G(z+Q~F@*0AVoC4v8gasv2H&Ahhs>e4{!5+!vBi66Eom0KoL zw?Y|0qe+%3X-mhW1IxB4%eGCXZ6~!2RUwRNAF!2=sT`0vR#U!V`mtGW=3mcey0N6I z%{3TR1=jg*F1fyh>Ex}1v(dENKtJ+rUeTjumSWBd5J@E4LGdy2fDE>X3`T2H-!*XMnT?U8vK_WmmC#Q z{Y@JoI&2p`CoXx2M1_e_XS?LNaJs$QW5~EX?dKsE?)5C_?0Bx-)6oqJ1DJ<+_U+tP z;pu6E&zwqTkf<5-=(eb2=l8a!8MahRyvbAvn2eJ)9nzoCAfk~JO5lb$-X821RJjNn zU2F)kC=oJ|Z_r4Yqrfc|4bj_y4B--ox|u2qy_?{^vOkcqRLNL6zWjE^O4j#DeE>An zFB9CL&9C)b?FnR7DVbH{<&&8!eVgx=F1)hhTK(7R$DNEC<{E@@QQf$A{G?p))E(Da z_`h|fu>KW_RY!gpNkVELY;`9AuUSB6wb~5Wycp^HV5>O2iGwh+R0-Vu5X_^CP1w;!uNAynU^yQW7ld`w?oTVGqpmvZOh{t70ekOe1M$FGI0ccLPWTwiYb(Oh zOXg1Om_g)bK@PHEgI1)2k)(DA)S`|dCJ{PT9aoHHGySlsEEeRJ^|yF+vI`hCaoTVN z85M0gbmAG76S@yCol#Q*(I}e*;q?h>D$x+ec*)G09VmUic5eKNN&RS1jpGTYf@=F( zmOT-U{M^PMf9bn3)Mg6R=Ad)HDaCjTwY<8WUXOMTF{Bd&XDSHJG>GpoO!;9&$l<=@5yjliA=5xV^xA7s!XhShf}u6KScIAABL?h`CyuohjGu8^7OGPYESf%wyjb zKM*fPiTQ&G-3xH9;5-;~Vj-@IaaF{wJh;lmRWZ9N!BrZrO4(Hzu3We(XIJxZ_m|*G+0t8a=s6S#>v!gqX)!72n^E4tu*z(McUiq(#Q73W#-C{_5p@=I~R5Laou!K8tt8Ai?Oh>}H}snqJk_!ZZ% zIVf4IM=LBd?rUe%L%I{JpA9C5wPNxo=pj!TEf&^4+ym|zzG^M&k7*dg;q~qsa6+`R z?o0MTEUE)3`Upye2(U)`8sk@7KaeVJ05ZAJa;`bXOPpgo_uS%LiK6u@jucI-M3ozv z!a-xWq^Z>At<%1x3^Re7oWriv3=do zb@yC=F}c{@CS5w~Z8-@5jdrnb1Kxh{+V6eM14*f>1-4rCGUI?VX6N%xU5OPB7&?EKDk4(bIn*;R_xW?Tt zdj9$w*HD6(bA9c7$UYWOAwYvW)mIkzI=rQMpz-zv>IB+H{PoM<$GfpTCr=WtO~?sH zWv!jlU)6DWh{^IjswB^Y*Is$2k7jrCS3>k#*RXQa%H@(st!fb7FCyafjxmJ03>@jh zcj%0$cra3|dJn}!mt!4~WMtQ)Og&@-4_R%Y-CsdV;`=xR5*rd8)Hg;94G47dEeFA-3KqSTGv#e2JitZ zk%%DndWiH{Z5O`G@ zBE&CJ?q8+AfF5DUD1^X-(av1q_7U0e;I%(|{l8AU>|tV0Y?knxd+QC`+0tEBQAFFm zV0*z1i9P9Cafv;)0kcT#sSRe9*lVi+kaZl7r?^6eHq<}}s~ zuovOnkmfAOgD;Rgc!y6)zr-c>bQO36sUg&Tfi}ck|I)S(4~h{JGREE(Ok*u#hZKtH zJkiN_e45BS#@lo1RA+mC!yH?aiLm)QMicYvK5AfE;nBdstlU6Wg_2d_&sr3OS#a{J z$#=3VKERpqsxWe5^!(3`yxS%pczRg4lYKZ$fc`7`#J{89?-6hT`f(3qK86xZx%XE; zd5OJLLN`jtr03BoN9+&cZP|ra5_t(eq>3eY9S$Nye6}|H>4!efnsJMbGfP`RDm z#kKtj>vp6-faAeBG#u$`NzYQVx-OAGx}^(4(%WNb+ne<=o^&Ao4%Gtr?m;_|L35o_ zS{EqYq?B$7lY&W$g)xNj9e9&toa z)xsp}Y!MH|@LQoc4#9H1wG&_|c#o~rp?H3mxZ)uf*OFEtY2xXgdM+^EnP8uX;+fS1 z%ns84ELLe++Q$jU(cZi{ay4<8hCS**MAga zb7!07^A8-h%w3LIMfAfCpZHQ2SneNW6+N^avB#|=7oC}bWRH^U8QF3>dGS5fyfr;g zQLj|g-@HI}lKIE4#0A|s*OIO#!8q^ql_bA=#XWa+Fufp{;~`P{y`r*vKs?ENHE(R| zM1wzP-Myj`&C-6nS1DaRS+oWge<`WIcG=Rhu9giqjO@8m=1-j;bZ38%ksrvIr)10< zEeDs>*L1gH={HxubJ(}zZhpzteWOvnru$rGZ73q-lm`nv!Gc0)A7m!MgSZPG#F+s2 zJPfusOf-?edgJ};_6{X`$7FW1Z!7HM^75yz(}i4EqomZx4d>oE!mg^6lqz|B$6KpS&rYk)s+E*#dBdrge(75=( zvv?x&p)Dq{@FTY^r|b_C(OjBrf20G@C}>zn`|&o3n}$0_w@%c^t^%f>z%)39cZ^o3 zUk{)Lp2_f07^s&bgTDmlyw5?s{=r2@R9p&}@h@*1+IB^n%sT|n*T1=!oK31RDR*6N z(uhgc^vQ+ggVi_V0oylUPbSA+#*UkV|9Id^1#zI)55j-zO4@Eyw*_^YKjcOy}(DGqg z`&LX*SRiQS1PhF)J9f;#awEbo;$ZAewT?(@^9(5_M;s#9o6wyn8G_1~vy-l8G$_X; z=z2QT^JJVy8O$I^GoWp6I7~H|h6c;f(5@F#O{J&JP{d(k@T4&oA(10E#LNN5Se6Dd zZdv+lF=s5-R7*PZ6eZ027b#)Xrzl~*xrAbY*I?T% z5NmrxmOZxF*tBz|i?(AX<_dPucw|NmC9Z+gNZJ42$aFtZXWr9HKy^Jj6w`hYpn4+X zWEk7lHbm498U<`8^(;=SpLPXK@v$C;&k+z(RFz zip%zk*#7}3k3jR7;so}6JxGV`BF*Z{p(L2FGHAVS;2O4eMs6M0BUKV=Y-+;a873O5 z9H|qHMOuTTnc#PM^z&hFplFp+v}&UEj%z)a-kZPaU%tYmcvN_(kW^Q@z-|iD0ck<= z8ucc*5YxKC_!j%gDvZDLjoB(}sp8TVGHx~AaqVFh#<*9ezRh@C-~chRMo;ivJbLr} zm!xLuw!~E7<^l%OW*0r!O#xGWu0j2yOE9({G3EN!;l|9#1LYH(#|6NE{c`Ya6YI^a zrc?1TEv&G-{4tQT_aM!REUvQiIm-0{p7Bk~AYG2pa_}~ zXcFgso?fTz6x;JKL{Ev~XA<@&M*%hTo!_EtXrQqMR~37h^(Y{E39d2`tt+`HIEq-k zXcrspHrDg~1%XTU=Z5bu%md;DYR7LNU+kI9nmk;cGvHdJxE9G(t^RJW>{{e^_0jf# z6n=`8Ne)@n|XR^B+ISQaFVapimQ+ z$hB?!LO#>HAp{P&bfvudj9+-}etOom9andZqz8&?l;WC+E`RzCz&Hvi3?51x%^W{5 zArU}S!^hE%xDED?1xMUo`y=2Yr3PIY!IWAKb~P(h41s5rgu$~l3^R1SVg{3hRQ7%* zsSv6O0EgxRyv3kZ%)nc$M0etg`Fw*$(hq87G`a2JPY}$gqG@M}jpzZ9hZR`~U7*Nl zxYl?WOtDdyxfu~bP+5?ixEpW8(auyuzp&|_pb4%bLPZQ8X}`ibQ_ImHslz8Wj)coK zbN(D31|B9`6G+i5OfB|#>i6O5IWwZS{vR|3HX~!x5E!kSMe&Suc;+j@{L$Dq6R#(Z zUkI$&p{&?(d&ORP_uf0gKF)5U(bz_9q#gQZ;IC0OVo$!{cp-MC4S^MxamcQ>25AIX zEUj7%50VcKi`EKTu;a9X8NYiGU_ZMS_Vl7ii6q6wNSM$OKBG%F@);*wabl4^%ivH3!>S>M|K|>9L>7y_N_n+ zP1qHtH|yy=4aX3o2Rf^q$_BAO(xNv>HujYWDp!i3MQ}H*p|LpoIAf+%nHver9LErW z!<_2`g_J^RP@j^vP_P}RZ8*^nns^yyd<7?8r~SO8Ii5DqtcJ1nY!F)Ky_lkhqO7K4 zEfzX$l+{okatuWe*v9Nyhhlolj^^ujiOq}A27ST^2Ppv`2I}FJfmfjTqif#m71ni#^@aI9C+Ohd;Z zIw5(Cfezc8nS_9#(DkLpOl&x$K(tK6VN6(fo)Ym8m&SAGoXx!qP6K!%q6{>@M;t~T zSbt!&kxxU1abPA9Q@V#S#{Je8pN(Xe4xsi=%rrA4nrNzO5N9+uEi6R#AK+nsyUCP zXk@e%9U-P7wh1o-q_$21Bliq3bI!SvI;z@wAY|3AdC@uKw9GdO`R1A}0Z^Md8LfQ@ zc!W;ysPo}+S^XLi-%h5r0rgan-q9w0dz4rN?2=-G<1UuWx$M#A8XJfnE7eJFt2U#u zmzm3csk~xd|9);6&<*nwZ~G~UB-9?2$pd=;57)BkyyO9O1p}5=kG^$f^ec2X=-%1( zHZmsgXbj1|64b@xm4y08`fW`k<7KX4H$4@QOJVAE#4MghLRXSX64S*}IdMM-eh*k> zT4q5*Q%O}^WB%N@G855ZW^JtZUxKeJcm)H>S*1iAI>y8Uo9=h$oRQf!bRiB7z-%Rm zXx}d_#?9-|oFy&bj=`7iBYA*UmhjC1<9&yHj>LTJ87#vOiE1iF0w_Tg<;#pMqNEWj zjjq$jb|~NjewQ%U?tXQ(r;FUWKnKA~^jAw&b&a%^UVeq%`aKFZ(%Rv~(>Q~)*&R(u(oHh@VKCRGnbTL}lXT(R|t zwh|5kM^>GnSqyzL!}qF(kpi|i)@hjSz@W^eLdxihq0pK`V{?^4?DM{3p+`rHGt;w! zyR;t#-vBV4sxJvuQ?%ok!BPGWh6H*xm#shnP3PJ2)jV6%h*Jk+i#CKqjt-G;o5*B_ zp%(bu#x=Iq%`^5= zVJXZ5f4Y~*2)3olySC)&l2>3FNtTI|sTFKXH5{YcjjdHO*G^pcY5%+Zw`wLccfoor z89OcjHk2st62H5QoqH6wM=sv)cOTF%@3>1oplJNjwma_ad=ExO5fOW^2<1lZpz*)a zsH5$ew)JA$tS{nileaOp|6*G&+OaoRB%5|uj5q!!#W%KCA5k4M)}cs0SXp3DujUuxG^k;>a0ADr2f|gx20UVyRa#ij8f%D&(4N^1efC-}UrA zM#shNus_OV8!r^23Te)~_ew7g+jwmpJ~1Ns%I?IguKeCcWw)Uf^n(^*x_gFCZc>0NZ%>&^^XT|7eS6zXgq~tMXAQmPmwosq5+OiX$(zJE~sHR0%A$)?Gi#6wu_sq?uo?F84 z0vN!QyY)aKnN!3;fC6Ffk&b-Cq4g35QdAuA5SL-kOQ$~<jSQRT~Y3z}hwjUDw*@LnIWf!-f~|E>4me*gRLzw`bN z-v8nIKYD-S{dYt0%dl0gU0%0h5wE3sll@cZzzFvQL3ujQ*XKSUg?_%qfv5@UXq);5$ zeK3nUsLhi%_)f4^B$|a8QlG|oH90=#x&mwgWOE=fShA{xm6rcuAN5FY(L)^#}(8AUF9-1Q?F~ebwBsG|MO7grw@qkghrH+ifCL|O9HWOA>%YG4H}2MG>+_N>u?w<7*YOwNtdgd*_ytKtp;;k z)2FQ*hFcuw>!D^iI*`3o$zFO#SjJag^JJ|r7ma9qxW@^DOA{e4ZP6DO3^WVQUHH_5 z!RBa&Z)Z3d>Fnt~1zkC8+*^Q@bPDb+V4Ro|cAFeDgz~s97wjk7p?j!8Cc?Oi9572Y zC@UkJG~RqDO-s#`QokpBp;hxU#ptA7RDw@LFVZ~B6qge+8+UZd{0e4_!@2;Kh z_o&Vul71(*as%H)`0~JkPZr(A(hH4ZeT!n8L5tYn_NQvKWc-@= z>rOMrT+A47j%Dh~4^}0OX38C=O_G6Sjk4t!U>jbd^VwmjkbKfop^o#l)Qn$ff~)Oe zH9jDT$O4kNsiYVq`OSSKuLIY2{O%Ws#j5VH`bz2Ux3D&liZNLw^B4xAEL$c#eZ>Ey z_kM)GKPHAVTe4_P3BKmR+65?DTF6x99yr8^0QMe?#GyzX{1al~;?A4QhS|h6mdep< zr5X9f(mXtFGrruk*BiiH6)V~NTs^lM3S_;w_KierzTW~|yKxgtthw5R!FgYZ z@8in4#FEFCGNvFIO!pDD0uBC^;17s_fAn$@;BnlU=*t2*mbzszaV^yXPjKi7CA>XPdo7c zhMK2cRV1HKUc64;cv3#~oP4qq%XOFiJb|1x*e^I9+H9#80gxLMl0FUJ@Z;#HIKs6h zg$eNO=BrG*?BaV_1+zGU4cCn&2HpkYDK6zsMB#Ur`#2;Q){*`KRll@Moz<*ZPwlqt*zI`_{Pq;r(;*-E->z?uR%$es})?|ubScWlVnh1oWGz}RI zY9I5pbRe%tpL4kuXm5cSH1)=fhg-Teu8l^e})oS)$amx{hI24N@LOf-u2oi zJ*Iw2w0L60*g}olXd0tdWm#k{3-WK!(lCA{r?_}f5SI+bb$bw_(RiYriIq&dG)`PP zD0rK+yNEA{%W!ACamW0%!0d~g^B0B9hmZNDs)%p07;j=VEAbA~3?3!ci1Urod$(w% zer&%q)w{VDh_$|W20XOoW>VunTa?@{#sf?WC*;Vek@RHP^OM({`A@^LF^LzB+r#GnH3bmVfj zrds;H=xtieE#Ka>abtzXQ3SdHDetMq-6%ZXRE@N%Fy-}Cutb0W`8K$KZ-RHotil=Y zA*5a=W+Z8{N-cQ14R7cNQI|}+<0wvM_Kv3QE5O-P=UF`koPeTJsZ{e_*kPb{={V_N z2woL-#;$`nA(vwn=+c39m|}PKw!no{yF{uKEZ%ho$BvKW?s0g%RTc~+tMm+xo~ABx zpSGkl>w}j^7SijL;`Nio4L2GCjmMP6W3u>MKs=|2=j6s?lZ}$>>AlPqNUp2+dtbz> zCe*!abon(3bY2@%+|`=u6?#NGSk4VL6#hsh^DY2DzlTm^Zez{~B>rDmOHsYEmg>gI zlk#S*+DB_0w5iV4N7Cw{wK%mMhnP^gvF_dr9p1LnBnjkj5u680{;;H-2+WMwPuo=g zKutwGoHf-OMXwhHiZ>|58*a@1^Cdr9GFiM^-g_{x_nfl#+~nR~+2g&;TZ;2d2@J5W z94}*VVE7QkCy+jaZxdYT^fCLdvaXY)cEEtbd08)ma{Onk>3_7S>2*r+x*M^7p7^uG z$>L^t_x`}{9%XmWORDzDf$IYn1#o z6Ptg!^WB}dRs^;mSGFI&z5S#to}A1-rBxnnSjZwXIED@h>5aKpLgq6D@elL|u(-TS zP_w}w>eWjtNmEl(&8B5sr~ccRXu~QNpZc$2vPhKLNy}TEnEx63uYf76msl-S`}<#O zd9@`_uv#ftJ+b1aPrdupWWm;3wSnztlwo4GtDCATU#%{5)_Ntse&Wzi zkG*^B*5+UA`1y{>{KIM$RV?J(^$wz$7(@6n70BSO8_1V$B0a8fo zT2Q#?$Pg#7MWcfrxakcCz40r$WPWA-p3dH(2doa5fay=^UJCAok{FCas1p1;PX>1s8y%8e|^ZmkRCUh_!`GtxPGIF?^oIu7}C1dSm#(IL{%O^XJbUB;wasl$b%(nQIv28g)E>V6_l6L6(x2hwYl^cp}+8fy1d!+S;!jjz11k;G(M zn6`eZoKt%75#rIY6C~Uw1QCe&(n@`Iuo2 z&8cKclSA=hJ-Ia-2}OY;8X{*+du9Exx*V_}tf)j`#Xv$x%$^@Tu|nH>3VM;b#f{eV|cK$~}Gl_2>P< zg@7=q2!ju7wnhhY_Ne;EJmNUvkcGiV@E6nOKxJM@AKpw(CEwXULn;G`Fo0AxI=11x z0eWw{gL0<#cD}mvo%Wlhf4=Bvi~I+kmD^AG8&3O$j)2gu2;H)`UlzLk!t+2Sa-{_l zJhu}(!R$={m&_>{+7?VqfBERp(c!0WCzc1ZYkXVp<#>j+eUQC~&I*0oc+s|wFT9tT zbM3jS&yD3wq~2)qXKsIl9=X&0f9~A#^ZoLnXK(HI?`&Ziyj(w2FXz?XPN=;Hs3Lc| zoYw@rQlvW8dE<>QD8OQ_ARnqVdUC@nZzq&$sjX+J zmA;ez-eP}yr~LeYWt!e;Y1a9~J4w~|)iO2ADHX{o6@OAS1|pL-%UMlK+FUet@MePl z$Z@&ntTq@l%avQwz4TK^1|!17M+8ZU&(nCQG?LlHlNmjHbA|u#Ov!Z3kPMet%ULV* zMC$xcKPz{2Tb1RoRU&udE2qmkF)MOCEF%6sv8>ZVn@HQbn9%VgwrH{C>lK>OBc;K# z5JgVWVfx*oX^Wahi>C*LR%M!es;yLnN=;Kg%rhvG)(;Zw(e*=eGRW+;W9B5tG^e2^ zMxq*xZkSQ)?4Q$Kn_L}P)IJmC&1|n74F^6+CXFq(UN`!Q0aySqBpsj#_S!^w;dn7- zD8@49C?>Kq`A$PTUDcbvOQD!%R=@VesMgZyOeZc&iwC_kvhB9eHxskwUj8u1q9llr za&>05Ro&4Sk%dCGScLB*1bn z6?Vor1xtd=neXbF)VKsIiF|F~8R?o4p$yKF$yS+z3o(lME3}(2q9H+sGxUUNX$;2` zdm!DUxVQC0D;W|qy(q?kV>ZOZO$4W92Mub*(W6m`DrUlwKHF>vr-C?j|0Sg+w$b z_@9JU#87YeUV^dG=mQK3+h|hK?q6;}ora}4O+SuaE2W8WBa*|W%L<@K*OXhLxR#8z zkMCpn(h=aG1dw>gwS=t?(iUn*rs+0BB{!JE&G;y^GizeT?=|ekm8BvL+NWx^YMUmD z8ZN6=YVNt1@xFirOkNEAba|NqW5ctR>W}p1%(y9`1UUMHqg-Z}Z`$e{r%z?zNGP6- zS@ml8m&7%RTp5|3l-2iU(br`mQyBHmpsd#n8gq~RB2&)0^y!uJ2oWh=UbgAh3ICxZ zfkP*hLnr)adgcBB|Di#@`*Q|F5|Mo?2_y*?b{a?$=ScV zw)n=1>BJ4dVWtr`7m3hX#_*lYNJ~eLv;9OXX*fZl;YzhXtKp8YaXJx{jQXao`p}PX zPHKA;JK_i<1qhRI8%#Z{2EL5r-XYk=biy=kW3QXz!OatG%>QF{-1+;xpWWbd894E z&%YUC(r3~aaeO~9ogxmgpdYm@7HTVoSoD~0^Joc(p0Q%>t+5i#U!G+TARwF4v2?A~ zpv_ULrGk2erianG1`479t8UUEnK$Rsg~7(a^=wYyv=IAsLQa1J7`< z5CDhx9S~`6dEO^EBqbCA<;8^_Zz3tOiTfh5f+{+UpjBqm2JX*9DU@q&US%!d0zZvO z+eFWRr=hhEx~1NBK$Qq5)bkIIBLV3?1us&-q{>7fUN^fQV?OJK=s6SBJ&P1SCUP11 zg4s(oO0u}Se~P+*oQV9nv8IrYQT|NyTS{lU5kM}<1ke=p5f3#uO{QlUcyRta6F4{F zYo!H9pdx23Yy7XMuzy6EG6pGAx@VkI#wulu&QyB(KPcY!DY#5Q1P*o|Mfnarpy`yE zPH5JBhMNBWhDZ_Sd|up&H0NW+_@Fnva7RbN+V1wwT3Yabj*6IJy$`zb9{|Kda(q@G z9>7&0Gd|ra(B}k?is|Lom|#70PNA8ZW>>7d-2*=&)jI13--zT zj?1mEBK@0vto(afxx=-Ayv0i1 z;!#P-s~WGF1gj)H&qP2FU`S#tSTson!iZKBa+DHGsn!UOoAv%@+huQ`>FtU0{*)#{ z3n8}YLM46SqhtYTxg*&<_XM8S($Chifc6VSbUZ9UJI{$kLEniytQI6~fj6l%6Zsi& zXtC@z=4RSb6gd0l3RAgNi4r0*!ljWH-my@^CVNUN@8I7zOoF9Mo60wPsdBZqU|(P=l?j4UXFzH(u`eJDp(HcDdRXob1JKc{ zvjFj(9wP`UU^9~Y@E`)NAsDV4i20O8gh`+8t8~e9uMA_ycaaX5M2tG4 zqDsXSFhxNIUicy9^Ped=h5r5FBRXR=u$m5CxF1-OieNDZds1yj=r^(>=+=qd{%MIuI+ zg<@Jfh>>(d8hdb@NpM#(DhnxPK8`RW5iS$pF4A7f`DA1j5*n= zq#zu{_j)w<078N_uKfHpIvb~$SLuwc;SLPmHeBfkkx;}^eW(lM9B1^0#>p9E9eKS3-t61yI#rA$}>|^$uC2SeE|6BOq*afv z{89Z|_1|9`sM)O4Y@V#y>QCDibY}(J#frOFp1)Ie7yI4Kn%@yK(1p}NorTzB*JC)f z_En^Ba!qgO$5Y$(dGK^P>nwaQaZ?SQbJj=Xvr*w{r`888$C3VQ<$=-(lodp!QTO=3 zaB1MOxmLX<1iVR=Ppcj(Ul#&ynJn6LSrr0i%!NV7NJxV%$XI|cAW@Flahc8xaUdf} zsryRA*^>AiJ=&2^dH&TUZ(GR2h+fB(YdTJJA$ra zVhgr?W^6%?3Ao%uYib%&VM>Muw(q^PJmS0+R0dNRp=*F}5VtNNCOzIKJb;^&l$R5S z5{GL>_Kvpt5~1L8FBR;*krVG6jMRrq?SSJKA(;>jh6xS$ywBwq>PWs={z#QCg3os^ zBcI&r8MztUz1+v}^=f7&##QFv68N8(h8xUbM_4;B5#<88x>-K-j4YmEGRo&P_5fs* zo9+lr(`A%8#e4*NZ&1wXFJ!!s`9jtU*`JB((wymLOx)_O6Wv%ptR{19nkKoV7)u*< z@F0LPz|Nu#uU<&eg3sgCISfQcgL6O&7NtSn*sSDJu&)JwfPAT`F|pgV_S*(b0qo*w z0>NP?MWiS(ok|4-9bK}B^DNxYR`2W5#gmRx(eR{3OD8)9VzfmK4Ag&bvoUWsu`Cn@ z;|z?;GZwPZ0jEI;L*Xc2G^BSDjs^`mbUdPYBY3d{53hyGV)i4?tYv;zV zxRP;ZQZFe_zSY_ndptcGim;(Tmub z+J91zf_48%nW2|745XY{ZM3F#hF-D*PwKU6u~-)o{7_GvW3i`hfjGxnPO=D7#)tolZQ zwt5*YCiFdF->ca`GaGMa409~*&#CdS}{9yGthat1Iyk}Lhq$sZ+lluM>l-kF^T0>d^xy( zU&RmizxynmkuM1D@{K|Gf;&aqCyB{Yb%AOhZ9d4d1B_%1kXU6}OFu$<1~6hgLR}Es zI+WbYpawLP?Ou*U!oG(fXTKAiz^A+wa-VJQ7NG}b#3M~+4WT$PG1Wa-FXuKh&=yu= zrdY?swG1xiSHw9sWU)+KTTEZ}3?ASsnf5<>hw-Hi>wprZCKb|0(cTpW3~S`?b1p3L z(~$_JCH)P8KHBG_vjKYp%8THXlUCtbK{;qT0O>LlYBMSJ5Pc`p zQY0q2)}&O}J~BZHehY2*7S|3n_tXzxqW45v>V1}p0;j1hkVcSAAl=b`W4D@`I^ ze-v~mrE^HL-w_|rpkwa70MUbwG)!5lq%Zni^j3&FzM_pA?))tzh#j!O>1-DP<5EJO zFiK@(4B1iUVBrO|cOebD{08J6KVVoqp=7PEki~X8%Ca71=_49m?=RX5lt1fhKe+n^ z56U%$KbRU};83rl;lo<481IHNiqXj_DxqXUAj8BS+$*8lf~M!psK~Q%*Up*`=OG;~ zrO+J`h6GK?w2ykUI@&euRxSoPM@?^fic$Y2`q-ON8Fj%F_F*UuI*y*L;o?x*hUabg z(+_Y!1~C7uQiu6B5997@>ci>(~=}> zN2qfthqQl&8cWb?SwH#YCH&;&Gib8`;0!>xWT{+e?4RJ-1$}`I5;e^bpY*4~;+5JA z*=?CkS~zI8e$&K5)G?Y28Lm0a!!{AhuAc)QC;z=*zhJunUg2!+BM>{R2tR)R zCG>u*$F!mv&PCvHm&Me@W=1XDcqFMgoKBY7fj-jR-sWu~u!b&^wCxxsuux_j97LXKhv!Nxy^lv@J%XK&%&*`>dv`2R$~0SbQ1BXGjEtyL03aS&Mdwy;wHrwnOD z+D~KEr4bVBp?>9UzvvCcaN;7As2eCyBEO=ey@)r9ypxT(KO=ePdIbOP<(cH2MI`S$GMg{c4>T$KawTo~WZDX!5QJ@G7NqVl zV$3WY9)udUFX4W2F{G9O7kQh2C=pfz!K@reINkBSXmUoHIL+;;A^4j&p28%41bmTH zFwd`7>UaeM2__M+hr#x?k;#n!XRy~MwX2tswAkLh( zboBYj@-n>9ojlvJhEwY z!|PkeH%x4}A^NuZUEA-u(gEKQ%*ef$=P_Yy_%qhvdw|yw&+$4+M;b;C5njh~{sN*7 zXS{OcULG7MWM6yc>N8_;6AlLUVfEUbq~#9^P{v0dTUPG1&a0gxweOVOIN{IS>PrX; zX#s)UD?$J8-1we}Jva8;^2$QCU)ay&$yvdaN|QW!E=DC;!L;I$MWbhwl2vl?>WRfS zo|pHvD4UPVO|3GFN_zS+MGe@S$du%)ox!#A*tgKUwZ(CgeokR4ho4@@`Y1#><=7B~ ztCNlf`bpgw1+_>rEU_{;F-eqXwl=w+o^@@<)g2@0??m5d@TYI~#RY{F=DxOSWY2ij zMAeO|Tl>k0?Os*^8BbOLX$8X_<5533_1?x`Z2$Rof6HmP=bV3sBwzYG=I!(i4)kcj ztxMBSku<$7n2>g5%k6}MU{3MK0wrhBXv63pIcF)&+HLkLP7DDu_Ze}HmIoHDRu-<7 zE7nZZO!WQq`FEe6=(<&XtNj;UKkvHLBE#Tze(N3giH|Uwx7pFb6Enz3%kU<-wAt^1 z2QD}F95Rv%Utn;-cptrC;Of9g<2$uCB!AX6pDQS&2Ly6YFx)z7A8(y#z0nHHJ6Xu_ z3kO(jWCv50JwbQKDjr!pF5TSh-+Lgi_n5Nxn7_SS?z`aMdl7B9-qGYhrDSh*m^1Dj zO&y=la$Jpik<-g>C(H}xmdK^GO73#1k{Q(n|{$K6de)5B^eC*UelTtyhy^Plm%j@u z-w3qJe93;H8h6yzDjt4jG+!yG^9w7WA2HlDaYo*=&wu1t;7Gf2q}|_jULK_W_l$i5 zORnK@$$ewrNZ#lXrFi9J!79J7`d(h?$WFQOfPAz?K7Gc2&Ko#4pqwLoq)jjhLvar{ z4pG9A{b~BiKMeGw?4Xbx5D3(1q-J#QIH0i#{KCq6Sw$m>^7=jU)6l&*>F+ul=(?zM zUBpWpfVqT}_Bi%Y65aOw^pkyHTHh#UeWUhXwr8YZeDBRB|DOGUJx7&2NBv@#>^<+_ zbHSf|(bQTarK1hw2U#=K8_U2#xpp(_*~RGDrrYD&Zd_vP!yf&nTv9Es-Y)Ndn%zIF z-yc~l*BNR2IyydXoBbmjg^#kCB|ZAX;#o9Z=1tRdfs%^m)`p{vI=BkqRjkkz%~BhY z3L*vV6m(ILMAVU+f&vOkDVRq=4F!x?AtVQBH3cyg9HJnW0)c`!3Qkhcjvy3$qNk@* z>Y?k46f{%7thk=1v#(I_3I(rHFhaoq1%nijwv+T81>dCLKT!}(!9xl@rocr(K9OL{ zDTt+D6$RwVN@B1?U!b#ZP{4fFyi8}i>08I>>}wQ|iiz|do!zA1Bn8h?kW5rf0tNT! zlELV9(%Ci&*3$z9DtMYMiDAevLL>&k$DlEpVi*~paR*yuWFYlXJR*Ul0m^p}K`%bT zrV>ZLq?;5FhqjkEcfa|P?UAFzSvR%CE;zT_AEw7T7fq$woz3>Cm?UTVlqbfSG3AbR zZnQs0wLP`LeruC_MaLN_!+-je4#XD=KvSaXq+u__}pAwRsDN`PY zvtmkcIM?Ia?yPzcljvM9>V0U#&s1TWGv(pJc<0io>}cnbDOZ#;e=0uFnKf1D#AQ+p zQbw-%RFuT2m@MZO`&4zNbMe%&d_1XVfy+5>D!<8&=(`!97a*FJR? zcUDZrmpc#Jr<(0<=Rx~a*&(O1cuI@ zs;Rmtv_^cHbJ^4myAu_d7-;UebPq*HI?*9^RJL}W>XACUr@JK5 z{|Y5+?V`Q&CQ0g%LTQA5(|Y!7dn^2&wqYBOJ$@*QZi?+ETl+e_U{H7VNG;ye?eI>i z?^`19AUX0R+h4KW7t@w}3z>01N!s^IPtzzMUX=7R^mFNJ6kMU;Dg~zyCFI(-wQ*C! zhP^HOb~Wvn9@1NnD0rI!wn?L{Stz!xr|WD-XS=kQQu`@h54pE*I^3{p!`{Z04IB4w z-POELIz-QoP%usbDSL+EHtv6}q0j5>>6R|jwSenQU@KqZo3uVE2;1!)q%+!4hN2LK zHIsA-mmFm0AkKa()T65UI@{Mu-@}I?zODB!5n?xNx4&%JTvj88)p#1r_XkaF1>WbxPo;|&(Ym2o+aM`ll1$}B1N zAUpT9*jHmmYX2a4Xv^p^#LUTiE$h{+k;XqL8hU!P3pplb`%b)kX6THZvuHAD@#Tam z0PJnDhlJfBVOvOO3JLokB*Y}dOu21wYeK^6kgz-?tO^P1BJS3OgnI0O+%X>&Wu!Y~ zn|o?;4F64a+E@JA9dFOYGHB0zlMZysE7+Jv+zo5P+J%*?&KE0~)3JKG;ParxWc6twS1{H8kFpVh@RMMeDwhaKVn E0@z&FfB*mh literal 0 HcmV?d00001 diff --git a/secure_sms/ui/__pycache__/main_window.cpython-313.pyc b/secure_sms/ui/__pycache__/main_window.cpython-313.pyc index 737a6c81b4885529b43ed7daf3e3e6dcdcfe323b..23b2c6e979dbc79db9dc1951908929442325804e 100644 GIT binary patch delta 4298 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 lS{8#$Q%~RxjH<^@;0OY1!jZ5qi5Bl){Ee self.max_length: + truncated = raw[:self.max_length] + # Temporarily unbind or use a flag to avoid recursion if needed, + # but delete/insert will trigger sync_display again. + # A simple way is to check if it's already truncated. + self.delete("1.0", "end") + super().insert("1.0", truncated) # Use super().insert to avoid immediate recursion + raw = truncated + + if self.on_change: + self.on_change(raw) + if raw: self._display_label.configure(text=ui_text(raw) + " |") else: @@ -229,7 +247,7 @@ class SecureSmsApp(ctk.CTk): self._touch_start_y = None self._active_scroll_frame = None - self._show_lock_screen() + self._show_mobile_launcher() self.after(50, self._enable_touch_kiosk_mode) def _enable_touch_kiosk_mode(self): @@ -675,6 +693,51 @@ class SecureSmsApp(ctk.CTk): for widget in self.root_frame.winfo_children(): widget.destroy() + def _show_mobile_launcher(self): + """Simulates a mobile home screen with a Saba icon.""" + self._clear_root() + + # We can use a slightly different background for the 'wallpaper' feel + # Using a dark gradient-like feel or just a premium dark surface + wallpaper_color = "#1A1C1E" + + wallpaper = ctk.CTkFrame(self.root_frame, fg_color=wallpaper_color, corner_radius=0) + wallpaper.place(relx=0, rely=0, relwidth=1, relheight=1) + + # Icon container + container = ctk.CTkFrame(wallpaper, fg_color="transparent") + container.place(relx=0.5, rely=0.4, anchor="center") + + # Large premium icon + # We use a rounded button to represent the app icon + icon_size = 92 + RTLButton( + container, + text='📨', + width=icon_size, + height=icon_size, + corner_radius=22, + fg_color=PRIMARY, + hover_color=PRIMARY_DARK, + font=ctk.CTkFont(family=FONT_TITLE, size=46), + command=self._show_lock_screen + ).pack(pady=(0, 12)) + + RTLLabel( + container, + text='صبا', + text_color="white", + font=ctk.CTkFont(family=FONT_TITLE, size=20, weight="bold") + ).pack() + + # Add a subtle 'mobile' hint at the bottom + RTLLabel( + wallpaper, + text='برای ورود روی آیکون بزنید', + text_color="#636A73", + font=ctk.CTkFont(family=FONT_BODY, size=13) + ).place(relx=0.5, rely=0.9, anchor="center") + def _show_lock_screen(self): self._clear_root() frame = ctk.CTkFrame(self.root_frame, fg_color=CARD, corner_radius=16, border_width=1, border_color=BORDER) @@ -1027,19 +1090,6 @@ class SecureSmsApp(ctk.CTk): self.header_card.grid(row=0, column=0, sticky="ew") self.header_card.grid_columnconfigure(0, weight=1) self.header_card.grid_columnconfigure(1, weight=0) - self.header_card.grid_columnconfigure(2, weight=0) - - self.mode_badge = RTLLabel( - self.header_card, - text='عادی', - fg_color=PRIMARY_SOFT, - text_color=PRIMARY, - corner_radius=6, - padx=14, - pady=6, - font=ctk.CTkFont(family=FONT_BODY, size=12, weight="bold"), - ) - self.mode_badge.grid(row=0, column=0, rowspan=2, padx=14, sticky="w") self.chat_title = RTLLabel( self.header_card, @@ -1047,21 +1097,12 @@ class SecureSmsApp(ctk.CTk): text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=16 if self.is_portrait else 18, weight="bold"), ) - self.chat_title.grid(row=0, column=1, padx=8, pady=(10, 2), sticky="e") - self.chat_subtitle = RTLLabel( - self.header_card, - text='در اینجا فقط دو حالت داری: عادی یا امن', - text_color=MUTED, - font=ctk.CTkFont(family=FONT_BODY, size=12), - ) - self.chat_subtitle.grid(row=1, column=1, padx=8, pady=(0, 10), sticky="e") - def open_contact_page(e=None): if self.current_contact_phone: self._show_contact_details_page() + self.chat_title.grid(row=0, column=0, padx=16, pady=20, sticky="e") self.chat_title.bind("", open_contact_page, add="+") - self.chat_subtitle.bind("", open_contact_page, add="+") RTLButton( self.header_card, @@ -1074,7 +1115,7 @@ class SecureSmsApp(ctk.CTk): text_color=TEXT, font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), command=self._show_home_screen - ).grid(row=0, column=2, rowspan=2, padx=(4, 14), sticky="e") + ).grid(row=0, column=1, padx=(4, 14), sticky="e") content = ctk.CTkFrame(self.main_panel, fg_color=BACKGROUND, corner_radius=0) content.grid(row=1, column=0, padx=outer_pad, pady=(0, inner_pad), sticky="nsew") @@ -1119,18 +1160,10 @@ class SecureSmsApp(ctk.CTk): font=ctk.CTkFont(family=FONT_BODY, size=16), ) self.profile_phone.grid(row=2, column=0, padx=18, pady=(0, 18), sticky="e") - self.profile_hint = RTLLabel( + + self.profile_key_btn = RTLButton( self.profile_card, - text='برای این مخاطب هنوز حالت امن فعال نشده است.', - wraplength=220, - justify="right", - text_color=MUTED, - font=ctk.CTkFont(family=FONT_BODY, size=15), - ) - self.profile_hint.grid(row=3, column=0, padx=18, pady=(0, 18), sticky="e") - self.secure_button = RTLButton( - self.profile_card, - text='فعال\u200cسازی ارتباط امن', + text='تنظیم کلید متقارن', corner_radius=21, fg_color=CARD, hover_color=PRIMARY_SOFT, @@ -1138,25 +1171,10 @@ class SecureSmsApp(ctk.CTk): border_color=PRIMARY, border_width=2, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), - command=self._toggle_secure_mode, + command=self._handle_profile_key_setup, height=42, ) - self.secure_button.grid(row=4, column=0, padx=16, pady=(0, 8), sticky="ew") - self.normal_button = RTLButton( - self.profile_card, - text='بازگشت به حالت عادی', - corner_radius=20, - fg_color=CARD, - hover_color="#F4F4F4", - text_color=TEXT, - border_color=BORDER, - border_width=2, - font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), - command=self._switch_to_normal, - height=40, - ) - self.normal_button.grid(row=5, column=0, padx=16, pady=(0, 14), sticky="ew") - self._configure_profile_card_layout() + self.profile_key_btn.grid(row=3, column=0, padx=16, pady=(0, 14), sticky="ew") composer = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=0, border_width=0) composer.grid(row=2, column=0, sticky="ew") @@ -1170,10 +1188,20 @@ class SecureSmsApp(ctk.CTk): corner_radius=10, font=ctk.CTkFont(family=FONT_BODY, size=15), wrap="word", + max_length=1000, + on_change=self._update_message_counter ) self.message_entry.grid(row=0, column=0, padx=(10, 6), pady=8, sticky="ew") actions = ctk.CTkFrame(composer, fg_color="transparent") actions.grid(row=0, column=1, padx=(0, 10), pady=8, sticky="ns") + self.char_counter_label = RTLLabel( + actions, + text="0/1000", + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=10), + ) + self.char_counter_label.pack(pady=(0, 2)) + self.send_state_label = RTLLabel( actions, text="", @@ -1248,6 +1276,15 @@ class SecureSmsApp(ctk.CTk): color = "#2E7D62" if modem['connected'] else "#B6465F" self.drawer_modem_label.configure(text=text, text_color=color) + def _update_message_counter(self, text): + count = len(text) + limit = 1000 + if hasattr(self, 'char_counter_label'): + self.char_counter_label.configure( + text=f"{count}/{limit}", + text_color=DANGER if count >= limit else MUTED + ) + def _refresh_contacts(self): for widget in self.contacts_frame.winfo_children(): widget.destroy() @@ -1310,35 +1347,41 @@ class SecureSmsApp(ctk.CTk): def _refresh_current_chat(self): if not self.current_contact_phone: self.chat_title.configure(text='یک مخاطب را انتخاب کن') - self.chat_subtitle.configure(text='در اینجا فقط دو حالت داری: عادی یا امن') - self.mode_badge.configure(text='عادی', fg_color=PRIMARY_SOFT, text_color=PRIMARY) self.profile_name.configure(text='نام مخاطب') self.profile_phone.configure(text='شماره') - self.profile_hint.configure(text='برای شروع، یک مخاطب از ستون سمت راست انتخاب کن.') self._render_messages([]) return contact = self.controller.get_contact(self.current_contact_phone) messages = self.controller.get_messages(self.current_contact_phone) self.chat_title.configure(text=contact.name) - self.chat_subtitle.configure(text=contact.phone) self.profile_name.configure(text=contact.name) self.profile_phone.configure(text=contact.phone) - if contact.secure_state == "pending": - self.mode_badge.configure(text='در انتظار', fg_color="#FCEBD7", text_color="#9A6C3C") - self.profile_hint.configure(text='درخواست ارتباط امن ارسال شده و برنامه منتظر پاسخ طرف مقابل است.') - elif contact.mode == "secure": - self.mode_badge.configure(text='امن', fg_color="#D9F5E8", text_color="#0F8A5F") - self.profile_hint.configure(text='ارتباط امن فعال است. هر زمان بخواهی می\u200cتوانی به حالت عادی برگردی.') - else: - self.mode_badge.configure(text='عادی', fg_color=PRIMARY_SOFT, text_color=PRIMARY) - if contact.has_peer_key: - self.profile_hint.configure(text='کلید این مخاطب آماده است. اگر بخواهی می\u200cتوانی دوباره ارتباط امن را فعال کنی.') - else: - self.profile_hint.configure(text='برای امن شدن گفتگو، فقط روی دکمه فعال\u200cسازی ارتباط امن بزن.') - self.secure_button.configure(state="normal") - self.normal_button.configure(state="normal" if contact.mode == "secure" or contact.secure_state == "pending" else "disabled") + + # Update key button text + has_key = contact.symmetric_key is not None and contact.symmetric_key != "" + btn_text = 'تغییر کلید متقارن' if has_key else 'تنظیم کلید متقارن' + self.profile_key_btn.configure(text=btn_text) + self._render_messages(messages) + def handle_background_refresh(self, phone: Optional[str] = None): + """Called by controller when background events (like incoming SMS) occur.""" + self._refresh_contacts() + if phone and self.current_contact_phone == phone: + self._refresh_current_chat() + + if phone: + contact = self.controller.get_contact(phone) + name = contact.name if contact else phone + self._show_toast(f"پیام جدید از {name}") + + def _show_toast(self, message: str): + """Show a temporary notification at the top of the screen.""" + toast = ctk.CTkFrame(self, fg_color=PRIMARY, corner_radius=20) + toast.place(relx=0.5, rely=0.08, anchor="center") + RTLLabel(toast, text=message, text_color="white", font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold")).pack(padx=20, pady=10) + self.after(3000, toast.destroy) + def _render_messages(self, messages): for widget in self.chat_container.winfo_children(): widget.destroy() @@ -1378,7 +1421,10 @@ class SecureSmsApp(ctk.CTk): bubble.pack(anchor=anchor, padx=8, pady=3, fill="none") state_val = getattr(message, 'transport_state', 'unknown').lower() - if state_val in ["sent"]: state_text = "وضعیت: ارسال شده ✓" + is_failed = state_val == "decrypt_failed" + if is_failed: + state_text = "🔒 وضعیت: رمزنگاری شده (نیاز به کلید)" + elif state_val in ["sent"]: state_text = "وضعیت: ارسال شده ✓" elif state_val in ["delivered", "read"]: state_text = "وضعیت: تحویل داده شده ✓✓" elif state_val in ["failed", "error"]: state_text = "وضعیت: ارسال ناموفق ✗" elif state_val in ["queued", "pending"]: state_text = "وضعیت: در صف ارسال ⏳" @@ -1388,7 +1434,7 @@ class SecureSmsApp(ctk.CTk): status_label = RTLLabel( bubble, text=state_text, - text_color="#6B8E85" if is_out else MUTED, + text_color=DANGER if is_failed else ("#6B8E85" if is_out else MUTED), font=ctk.CTkFont(family=FONT_BODY, size=11, weight="bold"), justify="right" ) @@ -1403,6 +1449,20 @@ class SecureSmsApp(ctk.CTk): ) text_label.pack(padx=12, pady=(8, 2), anchor="e") + if is_failed: + lock_btn = RTLButton( + bubble, + text="🔓 بازگشایی دستی پیام", + width=120, + height=28, + corner_radius=8, + fg_color=DANGER, + hover_color="#C0392B", + font=ctk.CTkFont(family=FONT_BODY, size=11, weight="bold"), + command=lambda m=message: self._show_manual_decrypt_overlay(m) + ) + lock_btn.pack(padx=12, pady=(2, 6), anchor="e") + badge_text = f"🛡️ {message.created_at}" if message.mode == "secure" else message.created_at badge_label = RTLLabel( bubble, @@ -1436,49 +1496,194 @@ class SecureSmsApp(ctk.CTk): if not self.current_contact_phone: self.send_state_label.configure(text='اول یک مخاطب را انتخاب کن.', text_color=DANGER) return + text = self.message_entry.get("1.0", "end-1c").strip() if not text: self.send_state_label.configure(text='متن پیام خالی است.', text_color=DANGER) return - ok, state = self.controller.send_message(self.current_contact_phone, text) + + # Show Send Mode Choice Overlay + self._show_overlay() + self._build_overlay_header("ارسال پیام", "نحوه ارسال را انتخاب کنید") + + container = ctk.CTkFrame(self.overlay_frame, fg_color="transparent") + container.pack(expand=True, fill="both", padx=20, pady=20) + + def on_send_normal(): + self._hide_overlay() + self._execute_send(self.current_contact_phone, text) + + def on_send_secure(): + self._hide_overlay() + self._prompt_symmetric_key_and_send(self.current_contact_phone, text) + + RTLButton( + container, + text='ارسال به صورت عادی', + corner_radius=27, + fg_color=BACKGROUND, + hover_color=PRIMARY_SOFT, + text_color=PRIMARY_DARK, + border_color=PRIMARY, + border_width=2, + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + height=54, + command=on_send_normal, + ).pack(pady=10, fill="x") + + RTLButton( + container, + text='ارسال به صورت امن (رمزنگاری متقارن)', + corner_radius=27, + fg_color=BACKGROUND, + hover_color=PRIMARY_SOFT, + text_color=PRIMARY_DARK, + border_color=PRIMARY, + border_width=2, + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + height=54, + command=on_send_secure, + ).pack(pady=10, fill="x") + + RTLButton( + container, + text='لغو', + corner_radius=27, + fg_color=BACKGROUND, + hover_color="#F4F4F4", + text_color=TEXT, + border_color=BORDER, + border_width=2, + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + height=54, + command=self._hide_overlay, + ).pack(pady=10, fill="x") + + def _handle_profile_key_setup(self): + if not self.current_contact_phone: return + contact = self.controller.get_contact(self.current_contact_phone) + self._show_symmetric_key_dialog(contact.phone, contact.symmetric_key or "") + + def _prompt_symmetric_key_and_send(self, phone: str, text: str): + contact = self.controller.get_contact(phone) + saved_key = contact.symmetric_key or "" + + self._show_overlay() + self._build_overlay_header("رمزنگاری متقارن", "کلید متقارن shared key را وارد کنید") + + container = ctk.CTkFrame(self.overlay_frame, fg_color="transparent") + container.pack(expand=True, fill="both", padx=20, pady=20) + + entry = RTLEntry(container, placeholder_text='کلید متقارن', height=48) + entry.pack(pady=10, fill="x") + entry.insert(0, saved_key) + self._register_text_input(entry, title="کلید متقارن", layout="fa") + + def submit(): + key = entry.get().strip() + if not key: + return + self._hide_overlay() + self._execute_send(phone, text, symmetric_key=key) + + RTLButton( + container, + text='تایید و ارسال', + corner_radius=27, + fg_color=PRIMARY, + text_color="white", + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + height=54, + command=submit, + ).pack(pady=10, fill="x") + + RTLButton( + container, + text='لغو', + corner_radius=27, + fg_color=BACKGROUND, + hover_color="#F4F4F4", + text_color=TEXT, + border_color=BORDER, + border_width=2, + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + height=54, + command=self._hide_overlay, + ).pack(pady=10, fill="x") + + def _execute_send(self, phone, text, symmetric_key=None): + ok, state = self.controller.send_message(phone, text, symmetric_key=symmetric_key) if ok: self.message_entry.delete("1.0", "end") - label = 'ارسال شد.' if state == "sent" else 'در حالت آفلاین، پیام به صورت شبیه\u200cسازی ثبت شد.' + label = 'در صف ارسال...' if state == "queued" else 'ارسال شد.' self.send_state_label.configure(text=label, text_color=PRIMARY) + self._refresh_contacts() + self._refresh_current_chat() else: self.send_state_label.configure(text=state, text_color=DANGER) self.refresh_all() - def _toggle_secure_mode(self): - if not self.current_contact_phone: - return - ok, state = self.controller.request_secure(self.current_contact_phone) - if ok: - self.send_state_label.configure( - text='درخواست ارتباط امن ارسال شد.' if state == "sent" else 'در حالت آفلاین، درخواست امن به صورت محلی ثبت شد.', - text_color=PRIMARY, - ) - else: - self.send_state_label.configure(text='ارسال درخواست امن ناموفق بود.', text_color=DANGER) - self.refresh_all() + def _show_manual_decrypt_overlay(self, message): + """Show an integrated overlay to manually enter a symmetric key for a specific message.""" + self._show_overlay() + header = self._build_overlay_header( + 'بازگشایی دستی پیام', + 'کلید متقارن برای بازگشایی این پیام را وارد کنید. در صورت صحت کلید، پیام رمزگشایی و ذخیره می\u200cشود.', + ) + header.pack(fill="x", padx=16, pady=(16, 10)) - def _switch_to_normal(self): - if not self.current_contact_phone: - return - ok, state = self.controller.switch_to_normal(self.current_contact_phone) - if ok: - self.send_state_label.configure( - text='گفتگو به حالت عادی برگشت.' if state == "sent" else 'در حالت آفلاین، بازگشت به عادی محلی ثبت شد.', - text_color=PRIMARY, - ) - else: - self.send_state_label.configure(text='بازگشت به حالت عادی انجام نشد.', text_color=DANGER) - self.refresh_all() + body = ctk.CTkFrame(self.overlay_frame, fg_color=SURFACE, corner_radius=22) + body.pack(fill="both", expand=True, padx=16, pady=(0, 16)) + + RTLLabel( + body, + text='کلید متقارن را وارد کن', + text_color=TEXT, + font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), + ).pack(anchor="e", padx=18, pady=(22, 10)) + + key_entry = RTLEntry( + body, + placeholder_text='مثلاً: my_secret_key_123', + height=46, + font=ctk.CTkFont(family=FONT_BODY, size=15), + ) + key_entry.pack(fill="x", padx=18, pady=6) + self._register_text_input(key_entry, title="کلید متقارن", layout="en") + + def handle_submit(): + key = key_entry.get().strip() + if not key: return + success = self.controller.decrypt_message_manually(message.id, key) + if success: + self._hide_overlay() + self._show_toast("پیام با موفقیت بازگشایی شد.") + self.refresh_all() + else: + self._show_toast("خطا: کلید نامعتبر است.") - def _open_contact_dialog(self): + RTLButton( + body, + text='تایید و بازگشایی', + height=48, + corner_radius=24, + fg_color=PRIMARY, + text_color="white", + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + command=handle_submit + ).pack(fill="x", padx=18, pady=20) + + + + def _open_contact_dialog(self, phone: str = "", name: str = ""): self.contact_form_message.configure(text="") self.contact_name_entry.delete(0, "end") self.contact_phone_entry.delete(0, "end") + if name and name != "مخاطب ناشناس": + self.contact_name_entry.insert(0, name) + if phone: + self.contact_phone_entry.insert(0, phone) + self.sidebar.grid_remove() self.add_contact_panel.grid(row=0, column=0, sticky="nsew") self.add_contact_panel.lift() @@ -1496,11 +1701,15 @@ class SecureSmsApp(ctk.CTk): self.contact_form_message.configure(text='نام و شماره هر دو لازم هستند.') return + # Translate Persian digits to English persian_to_english = str.maketrans('۰۱۲۳۴۵۶۷۸۹', '0123456789') phone = phone.translate(persian_to_english) - if len(phone) != 11 or not phone.isdigit() or not phone.startswith("09"): - self.contact_form_message.configure(text='شماره باید ۱۱ عدد باشد و با 09 شروع شود.') + # Normalize to canonical format + phone = normalize_phone(phone) + + if len(phone) != 11: + self.contact_form_message.configure(text='شماره نامعتبر است. باید ۱۱ رقم باشد (مثل 0912).') return self.controller.save_contact(name, phone) @@ -1609,7 +1818,42 @@ class SecureSmsApp(ctk.CTk): RTLLabel(avatar, text=initial, text_color="white", font=ctk.CTkFont(family=FONT_TITLE, size=36, weight="bold")).place(relx=0.5, rely=0.5, anchor="center") RTLLabel(body, text=contact.name, text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=24, weight="bold")).pack(pady=(12, 2)) - RTLLabel(body, text=contact.phone, text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=16)).pack(pady=(0, 30)) + RTLLabel(body, text=contact.phone, text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=16)).pack(pady=(0, 6)) + + # Mode indicator + if contact.symmetric_key: + mode_text = "ارتباط امن (کلید متقارن)" + mode_color = "#2E7D32" # Dark Green + elif contact.mode == "secure": + mode_text = "ارتباط امن (ECC)" + mode_color = PRIMARY + else: + mode_text = "حالت عادی" + mode_color = MUTED + + RTLLabel(body, text=mode_text, text_color=mode_color, + font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold")).pack(pady=(0, 20)) + + # Add to Contacts button for unknown senders + if contact.name == "مخاطب ناشناس": + RTLButton( + body, text='افزودن به لیست مخاطبین', + corner_radius=24, + fg_color=PRIMARY, hover_color=PRIMARY_DARK, text_color="white", + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=52, + command=lambda: (self._hide_contact_details_page(), self._open_contact_dialog(phone=contact.phone)) + ).pack(fill="x", padx=18, pady=(0, 15)) + + + # Symmetric Key button + RTLButton( + body, text='تنظیم کلید متقارن (Shared Key)', + corner_radius=24, + fg_color=BACKGROUND, hover_color=ACCENT, text_color=ACCENT_DARK, border_color=ACCENT, border_width=2, + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=48, + command=lambda: self._show_symmetric_key_dialog(contact.phone, contact.symmetric_key or "") + ).pack(fill="x", padx=18, pady=(0, 8)) + RTLButton( body, text='پاک کردن پروفایل', @@ -1617,12 +1861,41 @@ class SecureSmsApp(ctk.CTk): fg_color=BACKGROUND, hover_color="#FDE8E8", text_color=DANGER, border_color=DANGER, border_width=2, font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=48, command=lambda: self._show_delete_contact_dialog(contact.phone, contact.name) - ).pack(fill="x", padx=18, pady=(24, 8)) + ).pack(fill="x", padx=18, pady=(0, 8)) def _hide_contact_details_page(self): self.contact_details_panel.grid_remove() self._show_chat_screen() + def _show_symmetric_key_dialog(self, phone, current_key): + self._show_overlay() + header = self._build_overlay_header('تنظیم کلید متقارن', 'کلیدی که در هر دو دستگاه وارد می‌شود را اینجا وارد کنید.') + header.pack(fill="x", padx=16, pady=(16, 10)) + + body = ctk.CTkFrame(self.overlay_frame, fg_color=SURFACE, corner_radius=22) + body.pack(fill="both", expand=True, padx=16, pady=(0, 16)) + + entry = RTLEntry(body, placeholder_text='مثلاً: saba123', height=48, font=ctk.CTkFont(family=FONT_BODY, size=16)) + entry.pack(fill="x", padx=36, pady=(30, 10)) + entry.insert(0, current_key) + + def save(): + key = entry.get().strip() + self.controller.set_symmetric_key(phone, key) + self._hide_overlay() + self._show_contact_details_page() + + RTLButton( + body, text='ذخیره', + corner_radius=24, + fg_color=PRIMARY, hover_color=PRIMARY_DARK, text_color="white", + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), height=48, + command=save + ).pack(fill="x", padx=36, pady=(10, 20)) + + self._register_text_input(entry, title="کلید متقارن", layout="en", submit=save) + self.after(80, lambda: self._focus_registered_input(entry)) + def _open_settings_panel(self): self._show_overlay() header = self._build_overlay_header( diff --git a/tests/__pycache__/test_gsm_gateway.cpython-313.pyc b/tests/__pycache__/test_gsm_gateway.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a0a3a9065226ae9cd0ba30ff810195c09c37290 GIT binary patch literal 1526 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_symmetric_crypto.cpython-313.pyc b/tests/__pycache__/test_symmetric_crypto.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ca8c0874221dfd40bdcd40afed2bbad231c776b GIT binary patch literal 3241 zcma)8O>7fK6rT02?TxcZYO9bC0x*1^ zzIn6U)D%I`nnr##FNP5MlMnm`W2Y_O0d*5;NE6N>;%b2i&Nx5^ zOk>tKapZ7G$5y&9iCH+0DK#>f$(L|hxA9e*g$TAw#7YM|7hLgE;qfj11L7tc2kExt z%3a=E7aA3kL4QYGYr=rwj(nXA*H`_X|Ie(zc!NJT-stzVfd8$pEpfcbUmdNjCR_Zu zx*j{Yaa0=eb6`p1QUANK!#&XTz3oD(I9v&q?5Un(j}=%@8(27N&}`0}U?G|{4hQo*{i63)+Yg=Jciw5OZt1H=4$Vi7&qa<` zBPXU4i%Rs{-M0ca)T+`keReV0dP}^qbw0XlF1qXX=#Rs9hwmS)?oT`zsYZvUhZY+* ze^r`mRjZBNvr_jmI6!GG{N`Zws|r5w$=&B9bP83~Ca{kQM|P)x|4nkE2SJbqdNe76oKfvM|oP!oN;`#R43*7#U=^qgpFd|k0~MKo{| zcyYn~g;Z&{(v-zl<2De477N6#Gy{_ep6mfY_VgXcK{Z4=z#NPEh^^t7MGOiF zu}4S=I74lN*z}5NXIY~=fA*u2kz>(G>>|DP9b|JGw-_!On+z> zZiVODy64)u?+jGi`X0)M7Gz~!ZkvvN^+2{Y3spdY=>{F4owFFaH+=P@zq(6 z$1AZc&gBa6DN@MCr*b75*bJ8?zny?)P}+)Q7xW!mkloNzMJJ<#Q;WO;g7Uuwm!?b2 zolaUj98le}s8hR~!@36o_Sy^cy0L-n-0iC%Ah*q3GvZ=w&+Oil_tU?aKbb#Ys>X(# za!>qAnnY^@38n(e2tOCg! zIkSREH+=0NVJ!qCkJQAt!V4OUx+ZnEAo3{}84?%(WO&fnvl<$!zppF|*dcH7O0-)#sx?uzHCQ5K#x_%cqSB=9ivW0Kvf)`rKN%|Rj5)gt1pM=7}$ded| zXE8bG-cHU&9$wxsEnQce39lJUhHL}Nf!hc8aG(29$q=KME%#=PO5U)0i^R;Ews{%T zUiW=Odkt(%x(};1iqj>6b()9O0>MyfVQkuEr*J#ZEhd6__Y#puz*WM4;^73(dBG$e z5gCMT{l78!q-{`HSkRrAQa-=%$%RkfSrU=d?zsLJLId1K literal 0 HcmV?d00001 diff --git a/tests/__tmp_outgoing_secure_flow.db b/tests/__tmp_outgoing_secure_flow.db new file mode 100644 index 0000000000000000000000000000000000000000..a3429e7fb0770b446216e9ea2c2fcd9837e4b3ec GIT binary patch literal 45056 zcmeI*-)`Dg90zbaNkd7L(Q2#EF1os_v?OX-Fb)tpO&btG(oj-Ts6uY6gE>$SupQf^ zaqsA|s?wfiPqByCcGE}bw7XsIU;+t=on)1&E!Ec|*x1L1b3VVHZT>jsX{n&vRA}gC zhuDI|-Qakh`(6+@j+;IDVDV%l$Vmizkk;#}x&ZsyusF7##S*SWW| zKhOOV{4MxX;AU`@&BOo!2tWV=A3)$$bS4m%vB{oXh97g(uq^kJH;#VKY}1(v|YoMv>L_I9Dt`@Lbb) zu4_~%=XQLSyUmkr#~2h6id$u&SSb{|YpYY6sBKH8nsQE^ib;u0>oPmW^`CfD=a~jB z9!9SR!jTC7=2MrFFePG9Zx7^ggZqx;DmZO%iD2?^^+`=tY`!~fq zdi}gG8pO0^O&?{+`C+tmo3!QrI@TrEP@Z?S)@6F<7kh7uN^zr9$t}1^`al>l_9xx` zES^o7?}F3erxBiGeDuKDYqJ+rGU?j7`W<8GZDYFVU7t>HK@R?Beif0wOw)uf88 z>e^-ES<~yzXmh_{GZ^LQ1?6m$XqKU~2wOJWQCy}O9k%1C6Pt{h;+;=^*>^UL4)094 zF?(~ut9Pwt()Ti8#UQ70~NI_oK{=GM}cLRpyC`tKpy z^WIN#bV%{gJz0T9eO5OpHD#k)YpaUfqQ~L)eloXoL)DtpG)z?+?DM^gT26-r(!5l) zZDO(f7#u^74kX@{`o-M4bk|^Mbg5(0(zWSu_O{=SW|$JPIGmb(m7a7{>agOblv-zp zFx7U3$xX|zH4g&-r009U<00Izz00bZa0SG_<0v|@8 zGBLH(n=^=Iz0l3NyiZNFp;B|XK_w+wBhgws6-}j95_P4vtVq#DDi$YVvbGXaq9i3! zT2Dli@r1ZsBe7JxuB<2|R*S`xU-6UksZp&KY1@X`*Dh5lrB7$5)v2tWV=5P$##AOHafKmY;|xLN|YCYSip`~Tsm3KKUb=YylU{Z9q@ z-~Y3d{xLuR0uX=z1Rwwb2tWV=5P$##An*|j^xyyUp>H^L!vFyYKmY;|fB*y_009U< z00Izzz*&J_hM&9FoBaG1$MphSJRVJnF)=1B#}i3*;TG{>X9(Ag#r2JbT`29F&h9}jA2(B#<;rF%o7Z+qJK6N$`VtzuM3P&{GpR}vD;YVfmrX7D zgZflD*lFjR%~GLI*;A^dC0A7`z8h7kez5keyizi92Rd2J#GiL#h{C?y_> z$z88`{qz6%&|e(8VSoSxAOHafKmY;|fB*y_009U<;A0W^Wb$6{#3vOv|NmnNEcyWf z2tWV=5P$##AOHafKmY;|@D~`I|No8)z4I@FB@loB1Rwwb2tWV=5P$##AOHafTqS`U z>{kJTW54c<@Bgn-{LoeiKmY;|fB*y_009U<00Izz!21dG|NkG?|9?L_tOWrGKmY;| zfB*y_009U<00I!WDguM^|5YxeUX>Q3oe+Qk1Rwwb2tWV=5P$##AOL~?u|V%LKET~u z>g7-VPJrcfI@C5*MQ%|?wy5o1^Ixj%rPdx5t!hDf_DGXeO|L}F%~dPYsij|}3oB+t zitfJLbYyDAj8s0JYB;vC8Sm_vPBWg)?9-j9)0DEU?XsPzR;#A7wNYxQr8TeX|NjFY C?o5^d literal 0 HcmV?d00001 diff --git a/tests/__tmp_outgoing_secure_flow_1c591e35ff344426aae96c4b1f2c4610.db b/tests/__tmp_outgoing_secure_flow_1c591e35ff344426aae96c4b1f2c4610.db new file mode 100644 index 0000000000000000000000000000000000000000..0e49d832aa2e0fed01e626c74ce938877fbaf8db GIT binary patch literal 45056 zcmeI*T~FIq7zc10C}}_}R9hA8qN}?~1Jp8XhrD%~wiGg;fj~l1pf{FdpBO`82ir+Q z?$WWU(mu;R#XiKgn|_2&yW8bX91;kzfmNzDs=pOt$38we=l49e^J1Ls+`8@%u54OW z+2JD0G{dsY_Z-JC%;f1G9l66ey&pTB?u^)T(|(ha%-65yrb2%+6Sv+np)W(fPQ9J{ zdFq$oZ^54ecY@1wCI$#V00I#B00OW0i9mQ}hCR9O$fXLgiBh+SL>k0!?12Z9OZjxF zkmd@hr|W5MU=cSv!Fde2%B^J!>6LVz+sdzPr1E=QCcVd{iiOR!EUj%Loh>}}T(h=s z8iXsPcYT(-&6AwtTDK6F-7Ij~;`+LGZ7OkOy<$tmP%f!cv54#tRiekZ&J*|R9BSa) z34c2fo}XvmeCm?bWaW@JQrVKVYDewjgwi*qKn0^{LFkJ!ELM%t~wCP#Ie z)8w?i-ox7i`F3I9tRYv)V_J0XM`1h=zITs(9d+xh65EzFV)w>@fm(WV|GR8eb&DvD zZW`B#XUSBL`(9;8nccp$Y_by$p(KNc&v1#GfczEfa-;So25|YrH8h@1yuJG2mGbg^ipP)F8r1g=Da&dW6+>I90=SMdFB!7E7rIe?kkG> zg=l!QY=6N9!Vey>uL7>2T&qdcx2$VtQzHY8XY+j|X##h5Fy~%4dCN8AO8vslXt;rq zk@4`#1HY^|YmFqFO^?zSo3}eJIH%~}NT|((ey4XBAOHafKmY;|fB*y_009U<00I#B zFapJqv4!?jO}6cqrlm>^V(Dd_Sdo~Z#LLmBOo%AP;z^Y+$D@2q6qAv-tjI!4R@G#b z5H%517G*IR7nMkvh)SZoC??Bg^(%ICCfTpmmMf0=Y+0+5CZE;f@uqNar0qAK?`RXW zS0kZ6n9!f}4g&-r009U<00Izz00bZa0SG_<0yj(G?&t#BfB)b6RAFR#bSBuJ+xb+W z^Zh?P=^q0GAOHafKmY;|fB*y_009U<00JMeKs#rlm7$t2>oeb*&ja-Y@cn<&9YK#p?DmQR`k`!l0K(aw|zUgy&gxx0Pw> z$y$zIn%_IrGRyhZdZyJ7H_b+|oOrRmQWcL@jb>qUSJunccC1hoL|%-|^O1QmRuJM( zB8ew_;)U0|&iVgr=r4xeFhBqT5P$##AOHafKmY;|fB*y_@UaMdGWsxh?vo0f|NpTB z7X5$#1Rwwb2tWV=5P$##AOHaf_zQH;|9{7X-uaio5(q#50uX=z1Rwwb2tWV=5P$## zZj!(>{Z)YAz^^;w`~RC1KeQDB5P$##AOHafKmY;|fB*y_@O}cF|NqDJ|KHCJYe4`4 z5P$##AOHafKmY;|fB*z;ia_`Lf1L^GH>JgBCj=k>0SG_<0uX=z1Rwwb2teR}EYSXp z4KNQE+AHUOC%`_gR*7TjigZYhC7U?zHUG0)sa&F(Y_$^R*6yw;5iP%XBq}6fiF_*- zTe37|=iudHspZHqK5HDAS|%p!@+;L`gU=WDH&#{;b~e<;d_GxHpKqr2QYB}nc~HuF HUH|_dBXmtz literal 0 HcmV?d00001 diff --git a/tests/__tmp_outgoing_secure_flow_4cc013125e1c48ffa1d99090a7ad7356.db b/tests/__tmp_outgoing_secure_flow_4cc013125e1c48ffa1d99090a7ad7356.db new file mode 100644 index 0000000000000000000000000000000000000000..db9195f6415bbc3acd286a3fb640c8cda1911c39 GIT binary patch literal 45056 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 Y0SG_<0uX=z1Rwwb2tWV=5V%|5Z~V*~2mk;8 literal 0 HcmV?d00001 diff --git a/tests/__tmp_outgoing_secure_flow_6b2ac2e3d0a64e808e9fedbbe2b87934.db b/tests/__tmp_outgoing_secure_flow_6b2ac2e3d0a64e808e9fedbbe2b87934.db new file mode 100644 index 0000000000000000000000000000000000000000..ccb54d60a8b53f2c2c2539ec03d47d88efb618c2 GIT binary patch literal 45056 zcmeI)T~FIq7zc2h(4?VaX*EsJF1ngkDxi|pPKc9Knl_-}rG-!+5tJM2#6AfgiEV6$ zmfXQuRcW7PpJE?k+fBd1w7XsI*dc*{4e14%mi;Xf+sDTzKELPjTN1Lhv8uaNsF`+? zxI%`z&+$C>y&!NLH^v_K*rPi{*~DmfhK>0P(_xci+&Ay1#}j{ZV|PAsiLVpCj(@!O z^Y}0E-{L>T9>ia;LJSaq00ba#3j!zNSS&d?$)7%QNwq;8s*kE~85p|oBWN|n{s;MO$i620Ll)KIT!Q?)5^siv@VocF@LHm_{p z^r?6^mQ1Di51;#F7E$-9tJG{#Z+dE%=i_~FmzUn6U9StlEN5H7qSV*lYb)>T#-~(o zn%Z??w2190hS|%K>%-`pE@>#8mO8T|AoHO7R@yqwk!92D_kANXs?-dDf@?3{s@aC1bB+(d5&o{F}~B+G)U{2Q8M< z^sK$^?A^Y6yR>l1AWb^Ms!snXMPtc_5Bc|5zs)9f98#xFcO2_$rCa*%Wviy!RCRUJ z7$lxmQ#L*uBOrHqftL* zA0GHIQ%%Dqs_S&eclu)1Ee(m;`7!q5(K&bvy`<@N;s&Ryp2Cab;#_65EKC~SJw#(N z_(jf6DFM0{8!%|d>K3K8VzsIbT~+q!dH920%&%S3jXJe0TQ@q#{NSq2QIiGI9;n#{ zaaewI&Y@=~lHf+eVjf((WwA6G=-l+|PBgjjFzi6nO$kNnPDM}VB7RDpR{WIGHFgTq z-J|Y!v@fN)r6DO5a>8F&E-t*9Vhg_&r0L)evKSn)9`*(9hCB-h^$lxW4)+b!!$Nd* zw>-7#NGwZXzBRpmJ~h(kcwQbN$r8A8fVptt6fAd08m&tQqpKU38HpyB zCc?7fd}|cxd^*A|Ht%{Doa5{eshgAbEV*v7Pm_)@9N5-MPdKkS>$p2!rK@4{{OH97X5$# z1Rwwb2tWV=5P$##AOHafgbU#QKimdOAOHafKmY;|fB*y_009U<00P4zfcyVpi6r^~ z0SG_<0uX=z1Rwwb2tWV=5C|9W{{J7p{~zvyB@loB1Rwwb2tWV=5P$##AOL}35WwgE d!w^OE0Rj+!00bZa0SG_<0uX=z1R!v|z&|968JhqA literal 0 HcmV?d00001 diff --git a/tests/__tmp_outgoing_secure_flow_9a295a01ca6e4a8eaf46939f211106b1.db b/tests/__tmp_outgoing_secure_flow_9a295a01ca6e4a8eaf46939f211106b1.db new file mode 100644 index 0000000000000000000000000000000000000000..dbfd060ebc9be67ec549c0e3a4261177e76098cb GIT binary patch literal 45056 zcmeI*+fUn890zb4C~0U}sJ1EELsxf|3aDko&W%)>wveVYg@gb}3w^PCIZjOy$M_OR z-_o(E(zHKif5rZYZ7=;3I_<5GJ4r|&U_(}^+UUMkF1{R}ob&npY$w6QTN`V-LpZHr z)g_0EGBXUzGT(6=!!XzB{TjXZhe?T*-%;nRYJKSn^hf9}B>$w80EuSrv?t89j zzGxVPD`mHRmUo*cIxVwTh%2m@xI%et&AT?0IFeqoMPexD)TvlRa)>I@V_fHj2X&rl z;QV3aY9PF@z`p*pOJ+(+l{lhiNtJp>?euhT2=4UK4`?^&f;Y=iOK6ma`g?BSs&4#~ z;`N4lUKmZuazvvs$ddEJ=rkOuCU(}bM_faB;TqLZdgm8=Z;Nsvw^7c{b(8cyH(>0H zcKb7bHf6pIPKLJ@ScdYE1ADJV-%yE?>omI4v`@nsB=V={Oceu@;d}Sko!&;;tH36E zF3o9jR9}Dc>QKI&S~zD&b#k8;o&TPn41{mqWPglz>#P&omMX;VKL>_t>CgT5vQ^bB zqBwfP7$u%^Lv0N<_X{?IL5^Nf&aouJHXAg;wnKLmqco#VcRZ@(NQ0($=aXOdolT>| zTNB-wy}r?nnbI&ENpbA{^Od2P_2>G;?06r0a_{Wj!cNi*I&p#1Sx@d^_EEaLR^n!j z&S!|)toMr?9a21WPgY-{@CQJ^msR=4$Iw? z(p7p0)16lTd15G~`g46!%IAQed7RCx&e4V6aQwV?2WbqNw1-21yCBazLVdv+r^9_g zala6q-7MQ*vVrjJ+w7}A*HAJ|k@{+S<#=pt$nki-k0edt-VWx(8z*nMrc`rJ?TpSg zFgi9FUb*d;700a+`Q!0%`eyTL=MCpHy^n?3Oz3y|fB^y!fB*y_009U<00Izz00bZa zfe#~49-CNfPn(i$?>8(}Y!XY?bYjV}EGViLQ{^~MQcID96idodN+W`rl$B&GA;@t} zQw4>OOIkvXCR53!SVZOpo{wuWdFd`YKARfUYD+c8&5OnK%7Ief*x3`5Msl~cuUZ>Z zv{z%HKbX*;^Z^3|AOHafKmY;|fB*y_009U<00Ngw;PdfCcJTSX|EaC zxw7mm#LLOZdf9dJd@e7POMEd}$fq*Gwq?KE$&u*xeyL21+^V)+tP2q#u@H$a2#FG( zdJtQB5Q#n?bzbNEe>U_NLvI)$009U<00Izz00bZa0SG_<0ucCE1U?zR6Fl+t1Q z009U<00Izz00bZa0SG_<0+&UgcmBW5g!IeOVzd(i5P$##AOHafKmY;|fB*y_@E;av zf5rxwJB#g=lNS%LTlG3|EL{<+q$S$K>0Sq5bG^Eoed@*|(Pn)8N%PTi+TP6`#Er6{ zzkIG$E4g)E)ApZ5BAMF3@{7$>W&Lp_K_uBx)I{USS}wBJ61TIaB*n|ArrgY>%>CNa LmAvb9O@O}v4#HED literal 0 HcmV?d00001 diff --git a/tests/__tmp_outgoing_secure_flow_af5096e592964d9ba68836b78e51238b.db b/tests/__tmp_outgoing_secure_flow_af5096e592964d9ba68836b78e51238b.db new file mode 100644 index 0000000000000000000000000000000000000000..f607777bc15f533fc7a800d6fc47a2a7e5d1b271 GIT binary patch literal 45056 zcmeI)T~FIq7zc2hKtez)t)?m3MOSx~3aBt}-bj$9Ee(cJ((sm0;Kn+!PrxN{jO~Qw z62_)V(>}^R#XiKgn|_69cfH)PLjnmlWEa)6>~E3y?fAs!_dI?}jCZ&5noWgD-K-H? zh;t7(p69+51dih-*lUu#I$e+rjCMv?pT9BeH#ot4_49|R$Omp>>^&FxGV<%x`^lfC zehL2;{vq@*yuv1;g8&2|0D+GnaIrWMicU}Smyc~yu2PFC4U@{WNp;)m8JW!ONZFz! z6tge#lF&0tn3)hfI!zVU3q@&7+7Y&P)?a6L4ulQqK**Mgo9hKu+iR&%obz1LIIind zC`$W2^WD{x?UvClBosD_LZOt;d)KB?n`l)_rn+)Ror+0`O;wp4spUXxD9mH_O$UuqgGk_s+s6n*L9U*XrtB zVKj(o%X+<=C3lC>uG^$4JIh!DE}dhco?4L5R6$(b5Kj#nH8)>@&i=H-EPSdOU zI-}z~`PMgcMkh5o$BNE=F9t)=N00a)lWv_gYFXrnTAhBVrIx_mt9#_Zig zH)cv*w~1m~o&H!)%sNwjVs^ccy}5VxUPC8ox{bKU>9(h^BCTdi`JynbJJ%4^Y3~oY zI;42$-mE~UK8qWanzGR-S2aaGq1WN}{xG+6MbnR{X_%Vc-sgK4wOTb6NOPcStHfgY z(LRP=9Z0+@^^3W8>4w45XrN=$!dNhxd*rvH>7;}#c7}o%*?^l;mnAo)G?g8~G`rR5 zkM^WgXR1$1`5f?bFQweZ44e72AkKO>kj0?MTG$h~d-BX9)b}jWAMSgK`-SNCW_kVv zABsMC!e4}3Lt+>*^Q~w{*Fz&cj@Q$DBv}HtcQ7|joV?|lq}u4)8QosN_((9i_QWqM zuGdBuuZIKdWOLj(;hbWxk;pk0`JKI?g8&2|009U<00Izz00bZa0SG|g;|P>SMi%PEGdaZC6+2z#AHPz@p6h% zlF6v?Bq2mxN|uwPqCVpT)0u9q7OC22o7q-QTz#=$oIh>uHO0^hyxt4XE*($ zg8&2|009U<00Izz00bZa0SG`~hy|SMe?IbzV}Ix%009U<00Izz00bZa0SG_<0{?S? z^Edp|)APXRQI0zgC6md;j2IJR@kBDU#GcV2E|+rKJDJ0LM%U?H?Pw#`N+0Ct%1doA z*IM7-cyrd=s-_D0m9!%6Crawd-u8<#JGI?@lmvS+k=k-=d9$(7G)lXh>7pbzUaI=q zD`Fa%QYw{yb!;BS_iL}}$+waz@4tPU->tFx{L@WkDYds3Ta2aV7vuA>R8h<p@RSfAOHafKmY;|fB*y_009U SecureMessagingService: + db_path = ( + Path(__file__).resolve().parent + / f"__tmp_outgoing_secure_flow_{uuid.uuid4().hex}.db" + ) + + def cleanup(): + try: + if db_path.exists(): + db_path.unlink() + except PermissionError: + pass + + self.addCleanup(cleanup) + + db = Database(str(db_path)) + service = SecureMessagingService(db) + password_manager = PasswordManager() + meta = password_manager.create_metadata("admin-password-123") + db.set_security_metadata(meta) + service.cipher = StorageCipher( + password_manager.derive_key("admin-password-123", meta.salt) + ) + return service + + def test_prepare_outgoing_message_builds_flutter_compatible_secure_frame(self): + service = self._build_service() + + phone = "09121234567" + shared_key = "shared-key-123" + message = "hello from raspberry" + + service.add_or_update_contact("Test Contact", phone) + service.set_symmetric_key(phone, shared_key) + + frames, mode = service.prepare_outgoing_message(phone, message, symmetric_key=shared_key) + + self.assertEqual(mode, "secure") + self.assertEqual(len(frames), 1) + self.assertTrue(frames[0].startswith("@S:SYM|h1:")) + + payload = frames[0][7:] + self.assertEqual( + service.crypto.decrypt_symmetric(payload, shared_key), + message, + ) + + def test_explicit_send_key_is_cached_for_follow_up_secure_messages(self): + service = self._build_service() + + phone = "09121234567" + shared_key = "shared-key-123" + + service.add_or_update_contact("Test Contact", phone) + + first_frames, first_mode = service.prepare_outgoing_message( + phone, + "hello from python", + symmetric_key=shared_key, + ) + + self.assertEqual(first_mode, "secure") + self.assertTrue(first_frames[0].startswith("@S:SYM|h1:")) + + contact = service.get_contact(phone) + self.assertIsNotNone(contact) + self.assertEqual(contact.symmetric_key, shared_key) + self.assertEqual(contact.mode, "secure") + self.assertEqual(contact.secure_state, "ready") + self.assertIsNotNone(contact.last_secure_at) + + follow_up_frames, follow_up_mode = service.prepare_outgoing_message( + phone, + "second normal ping", + ) + + # Bug fix verification: it should now be "normal" if no key is passed, + # even if a key was used before. + self.assertEqual(follow_up_mode, "normal") + self.assertEqual(follow_up_frames[0], "second normal ping") + + # But we can still send secure if we provide the key again + third_frames, third_mode = service.prepare_outgoing_message( + phone, + "third secure ping", + symmetric_key=shared_key + ) + self.assertEqual(third_mode, "secure") + self.assertTrue(third_frames[0].startswith("@S:SYM|h1:")) + payload = third_frames[0][7:] + self.assertEqual( + service.crypto.decrypt_symmetric(payload, shared_key), + "third secure ping", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_symmetric_crypto.py b/tests/test_symmetric_crypto.py new file mode 100644 index 0000000..084c96d --- /dev/null +++ b/tests/test_symmetric_crypto.py @@ -0,0 +1,51 @@ +import hashlib +import unittest +import unicodedata + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from secure_sms.core.security import SymmetricCryptoService, b64u_encode + + +def _encrypt_for_key_text(message: str, key_text: str) -> str: + key = hashlib.sha256(key_text.encode("utf-8")).digest() + nonce = bytes(range(12)) + ciphertext = AESGCM(key).encrypt(nonce, message.encode("utf-8"), None) + return b64u_encode(nonce + ciphertext) + + +class SymmetricCryptoInteropTests(unittest.TestCase): + def setUp(self): + self.service = SymmetricCryptoService() + + def test_encrypts_new_hex_transport(self): + payload = self.service.encrypt_symmetric("hex transport", "shared-key-123") + + self.assertTrue(payload.startswith("h1:")) + self.assertEqual( + self.service.decrypt_symmetric(payload, "shared-key-123"), + "hex transport", + ) + + def test_decrypts_flutter_style_payload(self): + key = " shared-key-123 " + payload = _encrypt_for_key_text("hello from flutter", key.strip()) + + self.assertEqual( + self.service.decrypt_symmetric(payload, key), + "hello from flutter", + ) + + def test_decrypts_legacy_python_nfc_payload(self): + raw_key = "Cafe\u0301-123" + legacy_python_key = unicodedata.normalize("NFC", raw_key.strip()) + payload = _encrypt_for_key_text("legacy payload", legacy_python_key) + + self.assertEqual( + self.service.decrypt_symmetric(payload, raw_key), + "legacy payload", + ) + + +if __name__ == "__main__": + unittest.main()