From 65e999466d0517f7f1c3241bee135b8644529bb1 Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:11:52 +0100 Subject: [PATCH] Test gaps 4-6: relay unit tests, updater-refusal, password walk-through Closing the coverage gaps the review flagged as blind spots we'd be relying on at release: 4. RELAY LOGIC TESTS. server/test_relay.py (stdlib unittest + a FakeSocket, no network) covers the address-proof end to end: cookie issued on join, wrong cookie rejected, right cookie verifies once; enforce mode WITHHOLDS forwarding from an unverified address then delivers after it proves itself; watch-only forwards but records would-block; the per-IP cap counts across BOTH v1 and v2; a NAT-rebind clears verification (spoof-takeover guard); a forged BYE from another address can't evict the victim; and bad/short/unknown-version headers are refused. Wired into run-tests.ps1 (Start-Process from server\, SKIPs loudly if no Python) so a relay change can no longer ship past the gate untested. The relay had ZERO automated coverage before and auto-updates every user. 5. UPDATER SIGNATURE ENFORCEMENT. Extracted the two refusal branches into a pure VerifyStagedRelease gate and added UpdaterRefusesUnsignedRelease: no-sig refused, wrong-key refused, garbage refused, tamper (good sig over changed bytes) refused, genuine release accepted. ReleaseSigning only proved the crypto; this proves the updater actually REFUSES - the hijacked-release-stream threat. 6. STREAMING PASSWORD STRENGTHENING. The accept decision is now a pure ProfilePasswordDialog.RejectionAdviceFor shared by BOTH password dialogs (also fixes the App-review trim inconsistency - manager dialog compared untrimmed). Test pins the load-bearing rule: requireStrong DISABLES the unchanged-exemption so an existing weak "Games" can't keep streaming, while casual mode still grandfathers an unchanged password and blocks a new weak one, trim-safe. Plus the NVDA-hang cache assertions in PasswordRules (miss->hit, same-instance repeat, Prewarm, empty/weak = no work). Gate 71/71 + 7 relay tests. Co-Authored-By: Claude Fable 5 --- run-tests.ps1 | 34 ++++ .../remsound-relay.cpython-311.pyc | Bin 31076 -> 38865 bytes server/__pycache__/test_relay.cpython-311.pyc | Bin 0 -> 17196 bytes server/test_relay.py | 176 ++++++++++++++++++ src/RemSound.App/ProfilePasswordDialog.cs | 27 ++- .../ProfilePasswordManagerDialog.cs | 7 +- src/RemSound.App/RemSoundUpdater.cs | 50 +++-- src/RemSound.App/SelfTest.cs | 72 +++++++ 8 files changed, 336 insertions(+), 30 deletions(-) create mode 100644 server/__pycache__/test_relay.cpython-311.pyc create mode 100644 server/test_relay.py diff --git a/run-tests.ps1 b/run-tests.ps1 index 64da32e..c294033 100644 --- a/run-tests.ps1 +++ b/run-tests.ps1 @@ -93,6 +93,40 @@ if ($packet -match 'DefaultPort\s*=\s*(\d+)') { if ($Matches[1] -ne '47830') { F if ($relay -notmatch '47830') { Fail "relay no longer references port 47830"; $wireOk = $false } if ($wireOk) { Pass "relay magic / version / port still match the client header - no server change needed" } +# Relay logic unit tests (server\test_relay.py). The relay's address-proof, per-IP cap, NAT-rebind +# reset and forged-BYE rejection are pure Python guarding an internet-facing attack surface, and the +# relay auto-updates every user - a regression there would sail past the C# gate. Run them here so +# a server change can't ship un-tested. Needs a Python interpreter; if none is found we SKIP loudly +# rather than fail (the C# gate doesn't depend on Python being installed on the build box). +Write-Host "`nRelay logic tests (server\test_relay.py):" -ForegroundColor Cyan +$py = $null +foreach ($cand in @('py', 'python', 'python3')) { + $cmd = Get-Command $cand -ErrorAction SilentlyContinue + if ($cmd) { $py = $cmd.Source; break } +} +if (-not $py) { + Write-Host " [SKIP] no Python interpreter found (py/python/python3) - relay logic tests did not run" -ForegroundColor Yellow + Write-Host " WARNING: the relay's address-proof / cap / eviction logic is NOT verified on this machine." -ForegroundColor Yellow +} +else { + # Start-Process (not the call operator) so unittest's stderr can't trip $ErrorActionPreference=Stop, + # and so it runs FROM server\ where the test's relative import of remsound-relay.py resolves. + $serverDir = Join-Path $repo 'server' + $rtOut = Join-Path $env:TEMP ("rs-relay-" + [guid]::NewGuid().ToString('N') + ".txt") + $rtErr = Join-Path $env:TEMP ("rs-relay-" + [guid]::NewGuid().ToString('N') + ".err.txt") + $rp = Start-Process -FilePath $py -ArgumentList @('-m', 'unittest', 'test_relay') -WorkingDirectory $serverDir ` + -Wait -NoNewWindow -PassThru -RedirectStandardOutput $rtOut -RedirectStandardError $rtErr + $rtText = ((Get-Content -LiteralPath $rtOut -Raw -ErrorAction SilentlyContinue) + "`n" + (Get-Content -LiteralPath $rtErr -Raw -ErrorAction SilentlyContinue)) + Remove-Item $rtOut, $rtErr -Force -ErrorAction SilentlyContinue + if ($rp.ExitCode -eq 0) { + $ran = if ($rtText -match 'Ran (\d+) test') { $Matches[1] } else { '?' } + Pass "relay logic tests passed ($ran tests: addr-proof, enforce/watch, IP cap, rebind, forged-BYE, header gate)" + } + else { + Fail "relay logic tests FAILED:`n$rtText" + } +} + # ---- 4. CLI SURFACE + IN-APP SELF-TEST (these launch the app, which consolidates sounds away; # that's why the package checks ran first) ---- function Invoke-RsCli([string[]]$cliArgs) { diff --git a/server/__pycache__/remsound-relay.cpython-311.pyc b/server/__pycache__/remsound-relay.cpython-311.pyc index 293ce1a70f1c9abd7722ba5a94c60879879ccb09..71bb82dc48b13d633ead8c198b46bfbc48578f92 100644 GIT binary patch delta 14257 zcmb_@dsH0PnP*kM=ofSYP4kB0A)t9Q;w4K6WPt<{76J=l*+ST@rmGN*8XDg&B*Qdr z&56Ci4ksbLa-=w(F*9*&G_WD{x=`_wkqSr^mHQ!Pp|gOhJH&~_k8xrNUFM6^ zTjHEj;0TP<8t0S&ryMzsX|f> zM^;-4HHlBSOPXQ#!{fJC7AQ6fzRFy25UpL@L;cYYatbaS4r>aOUqn-|Zp*#U8`k71zlbJxJ-_I}k+7yn`9(BE>!p=pjXkUZ zioZxAK+5Ush7$fi#i!InwZ9GF%+ zZ8Sci>m>s8m|9NfwCsRDY7z!W4aS{Fa5hLIlBNXlLm=>V80RV9|JN}l>c6u*Jqv>u zJ9!w33dUk!W8p_kEM|1fw$<>=0`m1LRnao7uEp9Uqn;3UQ)`$1}Od_n$q?13MO_y z@fXoltQQtt7~CvtN?7^6*g{MX+TogU%M+w|&5{11I%7X_bxdanIY|H6T9Ai=5HklP zd3HuoCPGdDYiZ4zd7Rgh3J9-FW_^L@N9pgf>kCDL?jjTlD*-c zQ4C5*#*v_Pidj59FQ~uE3#yM+A^?V$6Yyce9NX5ZW`aU}g=XYF$(Pfw=AN!b;lIL@ z0(b?`%EikWT;o%+3nhpIfcq*H@~UANhx594zL!3k-`OYwJEoSV#Q+W_(O#BIlstY> zf>|3In{!ROrz8@B^lNLs%Rk3k=O9RWk(@(vo(2o6igD}?6}bSfSei7A#Z#5izc0+w zJ`PDgp#M}j7(z9@v)-T?zr;!02rqIYDv=*i8#vJ^Hj1Xlv?CgEr)U9JpcR|M9C+)* zW-$-mX=0052yeaE3T{dR=A=z5g||`MB?|C1iFb)*@HUIP#d3HbN*DKt6%fb(FI5R| z3wSB?R#qrb1Mf`8=zwc(38cDhl83R=YqKMoZ%p*NyNMu zOO_RRi0Kk(q6%VZiI|*8-+Z7j*N}|oBtjfFX`=g^?e&n|oM4I^3`jlDOLhRqkxmxj zMV>_R1td$ft)z!*JGrq#p zLqhz4ylR%uM4LJaL;O7OVThX9S0KL(<;sGwK@ zp-zw|Bth}hPADp1QSbB_!9Oj`%#s;@KoWj&;i8dgED#B7@|>Uuf=~nbXF_f-5&T3r z*DNrVatO@K1O%^KFSw^g)_~-h@Ixz8Qa~7Ud(JXm0QxAL2iL6_8!L34WCC&MVg~H1 zn-KRL83RsCd$j9N-+`F%M3d`8&(QI{!GV}%_~gAkuC9Xz$A(-74)+{5!fc*HO}>Kl zRAMQeGR6Q39CTf=H(8tH6^yLstR&Cp4KWCDd&)1MWT1E0t6^pvXho3OKZ?P?( zcw%8`;qtkNWk*=QLowWFo=85uAlMoO`PCv`(M)4}$6nqxlb%gRZR@Xj55^8tSCS z+U@ke@6F1?Mk+WNt$O^aghCO=q|D7u%U++500Tn6FSvz(&wpNEQWbzipD^W?0Z0U#1M?0L z(P_Ceg)I<%imq|D7uphJf9H;XAQ1vD01IU>2Mlh-@M{p8QF?ja;-Y+`BW-DDax zi@7U@mdR;M%O-<4H}V{2J&C?(v#r6RR%HmOAlWKm5OZ|CUs(KX`+HS;BZZ-kXko`< z&r<(-QR$*Ks?Xn0sSOYD>t^dx-%WGT4Rg`u;hV*+H;P-Y-uLQgq_{g;-2KMEh`BFn z?h7Y=pT2LgW4#aYn<>_DSg9jieE7$X@bCj~H@?#tJ~bLKKNvMX7{*^nd8O#5@NB5D zE(in=-oI&V>AIVH^KN5zr}`~}qkFgdt=(D(6T}}eV;tXr%x!T+XjDO}`Hwds0IH}0 zXQV-lka*CHKCYsFbI9b=f*K~Zi98V85|1y`1|g3sQ8sW7I4s_kj0d@t8b88MCQX8< znombRkaBxL`dNCJMpVldy4+kyWA&Dhr~yUW^ykpdz6uR>O{WaH;6dZbOzAlBPk}-Z z>5MlZO9W&b#Bav$otDvXD{39nd(fr=aNbW*4BGYLgx{Rdyg7-K){uo z1C9PPI0n`Zn0dwe*P|kOP`60{On!hVFt~!aV?mXdC!E8?2wk5q+@zFAMMk*=H5_p0 z$u}UA;w;NJ&;arj#?i+qx})(y0R~SO0AHdHH#vUHU-kV%t zZIso;+>-Cv2{Zn(I~{j+l2UK_>BEI{M_%aKT-#=fBbV zH>KY%-KHuVX;`sRJyZw3dz2URD>0RKhBQO>JW0`lNK%yl0V1F0FL82GSUGNyALm8> zF+GSNPZ#d5s$5V7RkvkOu?)#dX=9n4hCLbd@%u|cD59*;Cd_e`c^?E)^GAmX*!%Mn zAQSS07i7dgOFWX$cdy{~5m;Ny2@`&w7!ZzjJ>cpY7#`~DIqtd_Rt%7V0ffdGL=0&7 zykM|Bo4U^SEL!0E^B?0v>}mc8$D^up59Tg_dZm1)$mK)2@;sqZ6!P2IB7cIr#Pi%z zo~Sy`T~RBz0_AUj5TFiqOy%4e<7ddXAQp!O7nqp*-q3MInwB1!A^YY-6>%wnHKkj| zWu>zNHZ9z~z&>X(mm0bqz-8s9zv6xc!V}Kk55LQ0SNT_US08@WaBb)n%T>#A*HxhS zPf2Ir^!5>N9dPLXb;RU5(4Y87$txH>iR8Ay60|1se{__gO~ce|AEvE8Z0o<=bgk?A zy>B#qzwdh1YlGJYmycZoivJ)Her%WyZA=}Qog!bvKA?uhbm!c@S=cAax?M67xDERn zifKKtu_G%V=yY6-zMkvz5LobVc*L<)*OfhOjFljPH&LyMZ0C^+$nb_D3`OBl>7}lUYz?SBV(Rz+~eb3k1!?)qT~Zdfqq8zD+YjJnCgIl_8b=YcPD+y{Rqk${L{WUVG7pF zU>^YPgTX*D7s9o`tPD2cOaRd1G?y85uVU|wBpUPDB5$^QAAxGX36jGcx8O`-S}AeXnE zox9w+Zn8v8dX~Qwf3yM=jMG zoYs)BcfGo9u_tP-OggD`>&&_}=jpkXoaYN7x%JW9`deCcdd4Rl5O7dH^qkeUD8nY` ziPEQQm}29)$DvVtGKOQkm#pEiH9)`#gZ9!)p;}eI({`apQx4oUDnxnyi*%yA{{C$< z=!RrvrrRNu@bw6cW16$lj2uf7iGOBB5?LF{p3ruOZ-JdH`a!qd(&PerjvEwS(I*K! z&8+wkHi-UEaaz;I+F||wA-0I1a{I?*2n2hf0E{9Gp}UPEcZK^LeX&O18|n3$HdPD1 z_F2vMc-Xaiqc&e<)l&Z8i40Mda#XO!s;k?Ps|D1FJ}Lgh(c~hiYdLyg;UM>5!-6iT zn@oD&VBEtIA+e%s&;@zW>`jZf7ht|$!Z~LT4}KH3J(iqM0(J??a_C@&#ki0bO#9QC zL?v`&ucDYuu1*4_+w&%8zTm&D9&VUKaNQ6Z`j(<}e zrBVy)`o?5%ztKINu%jZl*vYpOPi znyuXrsBr!aXx(!YJz2@ayjnhJoYd1ueSWSH`kAaQ+0T5+H{^Dqda)YilM|Id~C6rqA^XO4;tRqRe|Bk)Smr7m+-R1Q7xGYa}=d@=YXTNZvxi zbn`U~-Hqfrk{=^soA^u_OE7u`3Df4+F|>%}1tg(Tq&Oj|B{+EzqX8felVY((k%8^U zbZpZK{1#1HH@wa*dFjnXn{U* zW~B{)4g}s&(fontP13T&&r;G*kh zPTh^1x=2n#G^YV=ZXqMf{Cc+S;;zfXPwiXYw{FY3X{)?pt6Vv@s#z7kdo*I}j@r7z zw(fOX!E*0%?}nDMmBac!C;z6c>V~aqMUL2->!Y^%#U8N#VT-VCwcWG|H>|>n?fK%> z*=SkYRr%WR4?82)V^Qm|u=N<*9iUx}`OTK>FNK~AU6xn6o;@G2R5RzHO3#HFE*itu z+Nia5)g7@mD#3NDeOX6OHqPXM_qnMrzM(I^EUp|}tpNWMMHl3xZ#V8J1}}CqyZT0U z^{RGNj%2q+vs+=C04v4fA2t3o?>|>|X%E+N*XxXZPWAORXP-m;wnN+JT>JUXPgJ!o z{5+`JnJzkNZ!@q6und>^& z>>8W(`b7F)S{wL3UE|v-`9gGpTNVLm=>Y-qAOADmzw2t#d02dLlL#c)L-ava${7Us zrA%f-l3Rl-#<&qt!RB|QKF_|?0=58sTKoZ9uzNvGuhf@ki7FWtBmO|WCI$zezN?U* zq2Iadiy^C`n9Go3lg54-qrX7HG?oFPGK{7aj%o1_@|aIgshR=~P{DAomCX~=txX64 zO2uq!s_0+uu1lk+Hg=x=diP+3$&j_d0a@$;fwls8sX|f7U!8sVQ5IOY=F#pwFWYN` zZiDJAgYke#`&PE|fKK}Zo%VofP1Al}opT3yAvfuA=VEB9w2*b|h_bQVKiJ)U($#xk zf4_3d6~mGJ6iHH$6d^&eQReIm7)njZ9p*y8icev_-9Q}m@y(2XK@OWhlrZu$BqxDv zkth0teJ%Xz+Q08Rrw%dW`95Z2rVw!n6XKY9#y_(i$*_u9qwKH~KWzfUJO^Po(PA)3 zBtOS$m>g>%#4MX)*8oyAZw6EJ!|q)EO{zKYb3i+PsK{4s=+^-4@cL!`f;NE=01l`b z9H=AvL7d^6Di3(Lq|Z)zvIK{&)>r66O;9hF(#%7J^jn9sRdN}PW6+l-&pd zyp=4P*<+{Q?8(hWNFIOSgarKm%Q||qr0K3?-jJTj_r4j?HccK|$`H8Ao zPW%$=2^t`+Fd0Lyj~|jf$vH`K05G;EQaLUT7!S*393|EH2jz}bnvB~f@n;_1@p($y zoM&1m?@#1`eI7H+VHp~bQX}HOpm{rzjHAAC%yY;HISkvO0`#vpRb#w=V?lm%t6Rc{ z>6Jr;htlOEsZzo87eGQ@N=nH>Mlb_DMpYt5{8Q8-*OlZZK1^47P#?_L-mk46myJUs z4=|hKbfM59h>1$g(NbLi_qGC_?r-wlYMY=qu%GVa{v_ z>PrCBe`{0)(`{L>2BDt#B=Ji){dG9CJFKF08(`#@Dww6OPjq>u0doRev`!isT(pAV zI1{N!!Eqv7Y-+rXpX4k}f`d4nNM~Si_d=%PATqZ&2y6)O;hA9O_Q_@VF!RyOr56Dm z!aIcyaiSer3OID|ij{<+bMw2D_4*|2O}i2oH(=k|9q>NW`9lwnS;!*j%IeN zg_azdHTAOX7IuJ3>5)Iz0Mcv*q*xBWgzrfp%*&^7FN zqVt10!9eXFhk>?>dIeyv+xoA2-#a-L_MC~FoQR&BxTRKQW_`i|fej2GIxaJJS@%Bu zaA1`;(x(Ueip#2&wHGtiOUvP9UazcP)<&&m>vcQvT|?g=EEG4P=HKeLI_Ii*#qqM^ zy9@6f8jajFdZTtUTs!(_#g~iU8}l#T1G^jXW2^fBiB?`UM$CJo=DlI_UgZcZ2~?$( z;nkLizA>tA4C@=$J{|1hb?Jyc(&^pD?1g^>wB{oK`KYm<{>3pVEjyk+eJC&Q-MTLA zy-liclkr%KHoVt)%&CnywZ~f4I*0y*-|5D$@C*{>nD=1lYe?Qhf;fbH1|+ewGD`o~ z@j}>VFb!9$x>U4v_`9%^@tfglKIb}S{Rj!e1Pr%*nO2=B+4)N>uTn$?r6Yhkm^c;D z$X?Icd84xTgyEwqDxdfq)cWZOi@E{VNQ%}l0WVdmdIh;_KsHxJUZr0<)u0+x(TNcW z;J{BtzS^n>;UNYjMj!*k1QZmSmH#q% zk{lyLw{X|#E6C-aqYpn=nv3IA`ip=Z`il=J$%_xxsqR;;MIW5u*`1#O2j2?^SxkAK zz%r~K-56rFr5Hm?NCY78VVOCpKt>l548b~snW}tb;5Q?P5tQ^52toa$$Y7;XIYUh2 zSp%#pmQ};{{n^=(Qh|UKXvp7U1%RyMBQ@ z)DH3+Busfe!;p#xqD5juf{s=3JLovc=M&^@2n3#i92esMLKnDo&C#&tC|I+G<_&e0 zwsC{I1F28b!s=x!$uep2ko-1Ukfy~`!P{t-Mq9VRZ6!Hoi`KTmZ6#Io<UJEwxHhNl3C3lamoJ|V7>Mr45S$o(0Ax}?@ z+0|Atla_lLinGM*t&m2vDZod}dD%`^Jw1Fb{m9dnowpVH!QN#q-6bC2^XZI~HwlLp z>Cg;&AZ$;B;W(vWnimUSfYr)NxME=s&&f%g1D9r!;9SPf8Gj~|AbkrrA25|&x1bKH zR|*(%``RHhZI_JBExC#kwTMOAF%h6OHEgp1Mxzf)&YX@!PKL0`7T_>ur4%>e_+t7a zsW7LA?X1DgniO`DyXfzwg2S7K`EtotM82REOGUxUKWSbx!%1)1WBLW1d?3}-WLY9C zchjZus>(l>n8*gRR6$3^3w9Z}C(L3g8Xx6Z<^y{SoZOd)Lq;@O>VkS5iLh8f|0yGnK6NHLeRFy(-s&?i z7+{9eNDIs|VrH94egJPmnHjP+E|`K^1tHUl2|M*nRBM3I$Uev5L}tPCQ9!+0zlo6Q zB40^go7jx3U4xt{T`4>6kG9cZwvJ42SQr z!l9m{#|Q6&D@sE>$L{MJVi(~O_u%GW2r<5C{(nl!sU95C!O@#?HrD{BV*c^?O_26G z9PoJqA>|-Uf`c&S%7;=CPV3|ex2zn;P5Whbg9Hx&CnPw{S8i+xHKH``p7qIse|%i% zgv))2^S^pH(DQmG;8r1=1>%XH!zuLoyfb*skRAPDAKRR}>I6?eyDuydzt87~<3YG! z0QUxGd=gwOsR6RZub@DZdpgkaykGu**o z2%@Z*mf+n+vI7Q!VYr?V<6R247{ywdtF46802*0_ZsKIWviYN#as$ZfgB-Ie$o0Lo z7Jm+ZKx?|G$-M#pb3$LTZYg}Ci_sG2y1gK3ZwTw}UNkNmuXfx@(_k4O z=<{^HHPhE-p3iurGTMDIY#xc2N22DDHSytQRp`3D>~Jl2z1G;5qrTp1@3X4kwrU~# zqfW!U4(=b*%8mf7;W6wm_BX5lu|K08=bZ*EaNcRQL-@yruF?Sq z_Y+p^Cl2Fai~1)m_Q4GGy9UhqZbmt9-rb26h2CxEG16j&NZ3(#%%+a%U2xb7OI;VB zuKhUGDb&>sQQ)!Y1WVAkU8^fCPYQd{>`=Ih1JzDq|Yy5wE-%@Szj@ zy}ko(EzJ6)edIpK2|w7CR3AT0hWKq26sETg}hIY+)_in$Kd`Y zW-4H1OjriUDMnM|7BW9Uk}QN-i{cFxV?)~sHdKra6(`GJCfFZ+B-rQGVg5VQTa5q7 Y=F|t$kMn%`26r3v(T2GecIy9s05A4CKL7v# delta 7445 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 diff --git a/server/__pycache__/test_relay.cpython-311.pyc b/server/__pycache__/test_relay.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8bc9d5ee53ed8def2dbc615b37b93e21a614b75 GIT binary patch literal 17196 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 BI2Zr` literal 0 HcmV?d00001 diff --git a/server/test_relay.py b/server/test_relay.py new file mode 100644 index 0000000..9cbbb25 --- /dev/null +++ b/server/test_relay.py @@ -0,0 +1,176 @@ +"""Unit tests for the RemSound relay's address-proof, per-IP cap, and eviction logic. + +The relay ships to the Pi and auto-updates every user, but its branch logic (cookie verify, +watch-only vs enforce, per-IP cap across v1+v2, NAT-rebind reset, forged-BYE rejection) had no +automated coverage — a one-line regression there would sail past the C# gate and re-open the +reflection / occupation / takeover surface the 2026-07-27 address-proof was built to close. These +tests exercise that logic directly with a fake socket, no network, no real Python needed on the Pi. + +Run: py -m unittest test_relay (from the server/ folder) +""" +from __future__ import annotations + +import importlib.util +import logging +import os +import struct +import sys +import unittest +import uuid + +# The relay filename has a hyphen, so it can't be `import`ed by name — load it from its path. +# It must be registered in sys.modules BEFORE exec so @dataclass can resolve its own module. +_HERE = os.path.dirname(os.path.abspath(__file__)) +_spec = importlib.util.spec_from_file_location("remsound_relay", os.path.join(_HERE, "remsound-relay.py")) +relay = importlib.util.module_from_spec(_spec) +sys.modules["remsound_relay"] = relay +_spec.loader.exec_module(relay) + +# A quiet logger so tests don't spam the console. +_LOG = logging.getLogger("remsound-relay-test") +_LOG.addHandler(logging.NullHandler()) +_LOG.setLevel(logging.CRITICAL) + +CID = uuid.UUID(bytes=bytes(range(16))).bytes # a fixed 16-byte client id for the v2 tests +CID2 = uuid.UUID(bytes=bytes(range(16, 32))).bytes + + +class FakeSocket: + """Records every sendto so a test can inspect what the relay emitted (cookies, forwards).""" + + def __init__(self): + self.sent: list[tuple[bytes, tuple[str, int]]] = [] + + def sendto(self, data, addr): + self.sent.append((bytes(data), addr)) + return len(data) + + +def v1_packet(pkt_type: int, payload: bytes = b"", stream_id: int = 1, seq: int = 1) -> bytes: + return relay.MAGIC + bytes([relay.V1_VERSION, pkt_type]) + struct.pack(" bytes: + return (relay.MAGIC + bytes([relay.V2_VERSION, pkt_type]) + struct.pack(" bytes | None: + """The most recent address-proof cookie the relay sent to addr (the 16 bytes after the v1 header).""" + for data, to in reversed(sock.sent): + if to == addr and len(data) >= relay.V1_HEADER_LEN + relay.ADDR_CHECK_COOKIE_LEN and data[5] == relay.TYPE_ADDR_CHECK: + return data[relay.V1_HEADER_LEN:relay.V1_HEADER_LEN + relay.ADDR_CHECK_COOKIE_LEN] + return None + + +def forwarded_to(sock: FakeSocket, addr, payload: bytes) -> bool: + """True if a packet carrying payload was forwarded to addr (ignores the cookie challenges).""" + return any(to == addr and payload in data and data[5] != relay.TYPE_ADDR_CHECK for data, to in sock.sent) + + +class AddrCheckV1(unittest.TestCase): + """The shipping RemSound client is v1-framed (pairwise); these are the load-bearing cases.""" + + def test_cookie_issued_on_join_and_verifies_on_echo(self): + r = make_relay() + a, b = ("10.0.0.1", 5001), ("10.0.0.2", 5002) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"aud-a"), a) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"aud-b"), b) + cookie_a = cookie_sent_to(r.sock, a) + self.assertIsNotNone(cookie_a, "the relay must challenge a newly seen address with a cookie") + # Wrong cookie must NOT verify. + r.handle_packet(v1_packet(relay.TYPE_ADDR_CHECK, b"\x00" * 16), a) + self.assertEqual(r.stats.addr_checks_verified, 0, "a wrong cookie must not verify an address") + # The genuine cookie, echoed back, verifies exactly once (idempotent thereafter). + r.handle_packet(v1_packet(relay.TYPE_ADDR_CHECK, cookie_a), a) + r.handle_packet(v1_packet(relay.TYPE_ADDR_CHECK, cookie_a), a) + self.assertEqual(r.stats.addr_checks_verified, 1, "echoing the right cookie verifies once, not repeatedly") + + def test_enforce_blocks_unverified_then_forwards_after_verify(self): + r = make_relay(require_addr_check=True) + a, b = ("10.0.0.1", 5001), ("10.0.0.2", 5002) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"join-a"), a) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"join-b"), b) + cookie_a = cookie_sent_to(r.sock, a) # captured before we clear the socket + self.assertIsNotNone(cookie_a, "A must have been challenged with a cookie on join") + r.sock.sent.clear() + # B streams while A is unverified → enforcement withholds it. + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"SECRET-AUDIO"), b) + self.assertFalse(forwarded_to(r.sock, a, b"SECRET-AUDIO"), "unverified A must NOT receive forwarded audio under enforcement") + self.assertGreater(r.stats.blocked_unverified, 0, "the withheld forward must be counted") + # A proves its address, then the same stream reaches it. + r.handle_packet(v1_packet(relay.TYPE_ADDR_CHECK, cookie_a), a) + r.sock.sent.clear() + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"NOW-DELIVERED"), b) + self.assertTrue(forwarded_to(r.sock, a, b"NOW-DELIVERED"), "a verified address must receive forwarded audio") + + def test_watch_only_forwards_but_records_would_block(self): + r = make_relay(require_addr_check=False) + a, b = ("10.0.0.1", 5001), ("10.0.0.2", 5002) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"join-a"), a) + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"join-b"), b) + r.sock.sent.clear() + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"WATCHED"), b) + self.assertTrue(forwarded_to(r.sock, a, b"WATCHED"), "watch-only mode must still forward (never break pre-5.6 clients)") + self.assertGreater(r.stats.would_block_unverified, 0, "watch-only must record who WOULD have been blocked") + self.assertEqual(r.stats.blocked_unverified, 0, "watch-only must not actually block") + + +class AddrCheckV2(unittest.TestCase): + def test_rebind_resets_verification(self): + r = make_relay() + addr1, addr2 = ("10.0.0.9", 6001), ("10.0.0.9", 6002) + r.handle_packet(v2_packet(relay.TYPE_AUDIO, CID, b"a"), addr1) + cookie = cookie_sent_to(r.sock, addr1) + self.assertIsNotNone(cookie) + r.handle_packet(v1_packet(relay.TYPE_ADDR_CHECK, cookie), addr1) # echo comes back v1-framed + self.assertTrue(r.v2_clients[uuid.UUID(bytes=CID)].verified, "a correct echo must verify the v2 client") + # The same client_id appearing from a NEW address must drop verification (spoof-takeover guard). + r.handle_packet(v2_packet(relay.TYPE_AUDIO, CID, b"a"), addr2) + self.assertFalse(r.v2_clients[uuid.UUID(bytes=CID)].verified, "an endpoint rebind must clear verified") + + def test_forged_bye_from_other_address_rejected(self): + r = make_relay() + addr_a, addr_b = ("10.0.0.1", 7001), ("10.0.0.2", 7002) + r.handle_packet(v2_packet(relay.TYPE_AUDIO, CID, b"a"), addr_a) + r.handle_packet(v2_packet(relay.TYPE_AUDIO, CID2, b"b"), addr_b) + # B forges a BYE for A's client_id from B's own address — must be refused; A stays. + r.handle_packet(v2_packet(relay.TYPE_LOBBY_BYE, CID), addr_b) + self.assertIn(uuid.UUID(bytes=CID), r.v2_clients, "a BYE from a non-registered address must not evict the victim") + + +class Caps(unittest.TestCase): + def test_ip_cap_counts_across_protocols(self): + r = make_relay(max_clients=10) + # Four v2 clients from one IP fill that IP's quota (MAX_ENTRIES_PER_IP == 4). + ip = "9.9.9.9" + for i in range(4): + cid = uuid.UUID(bytes=bytes([i]) + bytes(15)).bytes + r.handle_packet(v2_packet(relay.TYPE_AUDIO, cid, b"x"), (ip, 8000 + i)) + self.assertEqual(len(r.v2_clients), 4) + # A v1 peer from the SAME IP must be refused — the cap counts both protocols. + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"x"), (ip, 8100)) + self.assertGreater(r.stats.rejected_ip_cap, 0, "a 5th entry from a capped IP must be refused") + self.assertEqual(len(r.v1_peers), 0, "the over-cap v1 peer must not be admitted") + # A different IP is unaffected. + r.handle_packet(v1_packet(relay.TYPE_AUDIO, b"x"), ("8.8.8.8", 8100)) + self.assertEqual(len(r.v1_peers), 1, "a peer from a different IP must still be admitted") + + +class HeaderGate(unittest.TestCase): + def test_bad_headers_rejected(self): + r = make_relay() + r.handle_packet(b"XY", ("1.1.1.1", 1)) # too short + r.handle_packet(b"BADX\x01\x02" + bytes(6), ("1.1.1.1", 1)) # wrong magic + r.handle_packet(relay.MAGIC + bytes([99, 2]) + bytes(6), ("1.1.1.1", 1)) # unknown version + self.assertEqual(r.stats.rejected_bad_header, 3, "short / wrong-magic / unknown-version must all be rejected") + self.assertEqual(len(r.v1_peers), 0) + self.assertEqual(len(r.v2_clients), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/RemSound.App/ProfilePasswordDialog.cs b/src/RemSound.App/ProfilePasswordDialog.cs index 3fb9d9d..8c84db6 100644 --- a/src/RemSound.App/ProfilePasswordDialog.cs +++ b/src/RemSound.App/ProfilePasswordDialog.cs @@ -13,6 +13,21 @@ namespace RemSound.App; /// internal static class ProfilePasswordDialog { + /// The pure "should this password entry be rejected, and why" decision, shared by both + /// password dialogs and unit-testable without any UI (2026-07-27). Returns the plain-English + /// advice to show, or null to accept. Rules: an empty entry is not judged here (the + /// requireNonEmpty gate owns that); a CHANGED entry is always judged; an UNCHANGED entry is + /// exempt UNLESS — the streaming gate's mode, where the whole + /// point is that the current password already failed the rule, so re-entering it must be + /// refused. Both sides are compared trimmed (fixes the App-review trim inconsistency). + internal static string? RejectionAdviceFor(string entered, string current, bool requireStrong) + { + entered = entered.Trim(); + if (entered.Length == 0) return null; + if (!requireStrong && string.Equals(entered, current.Trim(), StringComparison.Ordinal)) return null; + return RemSound.Core.PasswordStrength.Critique(entered); + } + public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false, bool requireStrong = false) { var (dialog, textBox) = Build(profileTitle, currentPassword, requireNonEmpty, requireStrong); @@ -92,15 +107,9 @@ internal static class ProfilePasswordDialog textBox.SelectAll(); return; } - // Strength gate (2026-07-27, with the derivation-cost raise). Normally NEW or CHANGED - // passwords only — re-accepting the existing password unchanged passes, so an old weak - // password never traps the user inside a casual visit to this dialog. requireStrong is - // the STREAMING gate's mode: there the whole point is that the current password failed - // the rule, so the unchanged exemption is off and a stronger one must be entered before - // audio can flow (Ed, 2026-07-27). The critique text says exactly what to do instead. - if (entered.Length > 0 - && (requireStrong || !string.Equals(entered, currentPassword.Trim(), StringComparison.Ordinal)) - && RemSound.Core.PasswordStrength.Critique(entered) is { } advice) + // Strength gate (2026-07-27) — the decision lives in the pure RejectionAdviceFor so a + // test can pin it without a modal dialog (and both password dialogs share one rule). + if (RejectionAdviceFor(entered, currentPassword, requireStrong) is { } advice) { var page = new TaskDialogPage { diff --git a/src/RemSound.App/ProfilePasswordManagerDialog.cs b/src/RemSound.App/ProfilePasswordManagerDialog.cs index 91e0a68..c15624c 100644 --- a/src/RemSound.App/ProfilePasswordManagerDialog.cs +++ b/src/RemSound.App/ProfilePasswordManagerDialog.cs @@ -86,10 +86,9 @@ internal static class ProfilePasswordManagerDialog { foreach (var (title, original, box) in rows) { - var entered = box.Text.Trim(); - if (entered.Length > 0 - && !string.Equals(entered, original, StringComparison.Ordinal) - && PasswordStrength.Critique(entered) is { } advice) + // Same shared decision as the single-password dialog (casual mode: unchanged is + // exempt, changed-and-weak is refused) — one rule at every door, compared trimmed. + if (ProfilePasswordDialog.RejectionAdviceFor(box.Text, original, requireStrong: false) is { } advice) { var page = new TaskDialogPage { diff --git a/src/RemSound.App/RemSoundUpdater.cs b/src/RemSound.App/RemSoundUpdater.cs index e70a237..fb0d724 100644 --- a/src/RemSound.App/RemSoundUpdater.cs +++ b/src/RemSound.App/RemSoundUpdater.cs @@ -217,27 +217,19 @@ internal sealed class RemSoundUpdater await src.CopyToAsync(dst, token).ConfigureAwait(false); } - // Signature enforcement (2026-07-27): the zip must carry a valid signature by the - // embedded release key, or it is NOT installed — this is what stops a compromised - // release stream (e.g. a hijacked GitHub account) from silently shipping code to - // every user. Missing signature = refused too: every genuine release from 5.6 on is - // signed by build-release.ps1, so "no .sig asset" is itself a red flag, not a legacy - // case (older releases are BELOW this version and the updater never downgrades). - if (string.IsNullOrEmpty(info.SignatureUrl)) - { - Log?.Invoke("updater: REFUSED — release has no signature file; a genuine RemSound release always ships one. Install left untouched."); - TryDeleteDirectory(stageRoot); - return false; - } - var signatureBase64 = await http.GetStringAsync(info.SignatureUrl, token).ConfigureAwait(false); + // Signature enforcement (2026-07-27) — the release must carry a valid signature by the + // embedded key or it is NOT installed. The decision is a pure gate (VerifyStagedRelease) + // so a test can pin the control flow — the thing ReleaseSigning's crypto test can't see — + // independently of the HTTP/Process machinery around it. + var signatureBase64 = string.IsNullOrEmpty(info.SignatureUrl) + ? null + : await http.GetStringAsync(info.SignatureUrl, token).ConfigureAwait(false); var zipBytes = await File.ReadAllBytesAsync(zipPath, token).ConfigureAwait(false); - if (!UpdateSignature.Verify(zipBytes, signatureBase64)) + if (!VerifyStagedRelease(zipBytes, info.SignatureUrl, signatureBase64, Log)) { - Log?.Invoke("updater: REFUSED — the release signature does not verify (tampered download or not signed by the RemSound release key). Install left untouched."); - TryDeleteDirectory(stageRoot); + TryDeleteDirectory(stageRoot); // refused → leave the install untouched return false; } - Log?.Invoke("updater: release signature verified"); Log?.Invoke($"updater: extracting to {appDir}"); System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, appDir, overwriteFiles: true); @@ -291,6 +283,30 @@ internal sealed class RemSoundUpdater } } + /// The pure signature gate: may this downloaded release be installed? False (refuse, + /// install left untouched) when there is no signature asset, or the signature doesn't verify + /// against the embedded release key — the two branches that stop a hijacked release stream from + /// shipping code to every user. Extracted from so the + /// control flow is unit-testable apart from the HTTP/extract/Process machinery (the crypto alone + /// is covered elsewhere; this pins that the updater actually REFUSES). A missing signature is + /// refused, not tolerated: every genuine release from 5.6 on is signed, and the updater never + /// downgrades, so "no .sig" is a red flag, not a legacy case. + internal static bool VerifyStagedRelease(byte[] zipBytes, string? signatureUrl, string? signatureBase64, Action? log) + { + if (string.IsNullOrEmpty(signatureUrl) || string.IsNullOrEmpty(signatureBase64)) + { + log?.Invoke("updater: REFUSED — release has no signature file; a genuine RemSound release always ships one. Install left untouched."); + return false; + } + if (!UpdateSignature.Verify(zipBytes, signatureBase64)) + { + log?.Invoke("updater: REFUSED — the release signature does not verify (tampered download or not signed by the RemSound release key). Install left untouched."); + return false; + } + log?.Invoke("updater: release signature verified"); + return true; + } + /// If the zip extracted to a single subfolder (typical when GitHub zips a tag), /// return that subfolder so the copy works from the inner level. Otherwise return the /// staging dir itself. diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index b2b49f3..b1321a5 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -129,6 +129,8 @@ internal static class SelfTest RunStep(results, "Service startup volume (boot-once decision + settings round-trip)", ServiceStartupVolume); RunStep(results, "Update install window (same-day, wraparound, retry timing)", UpdateInstallWindow); RunStep(results, "Release signing (verify, tamper, key-embed match)", ReleaseSigning); + RunStep(results, "Updater refuses an unsigned or badly-signed release (enforcement flow)", UpdaterRefusesUnsignedRelease); + RunStep(results, "Streaming password strengthening (existing weak password forced up)", StreamingPasswordStrengthening); RunStep(results, "Password strength rules (gate + derivation refusal)", PasswordRules); RunStep(results, "Relay address-proof echo (AddrCheck round-trip)", RelayAddrCheckEcho); @@ -2455,6 +2457,76 @@ internal static class SelfTest return "round-trip + tamper + wrong-key + garbage all correct; on-disk private key matches the embedded public key"; } + /// The updater's signature ENFORCEMENT control flow (2026-07-27) — the piece + /// ReleaseSigning's crypto test can't see: that the updater actually REFUSES a release with no + /// signature and one whose signature doesn't verify, and only proceeds on a genuine one. Guards + /// the hijacked-release-stream threat the signing was built for. + private static string? UpdaterRefusesUnsignedRelease() + { + var zip = new byte[8192]; + new Random(99).NextBytes(zip); + + // No signature asset at all → refused (a genuine 5.6+ release always ships one). + Check(!RemSoundUpdater.VerifyStagedRelease(zip, signatureUrl: null, signatureBase64: null, log: null), + "a release with NO signature asset must be refused"); + Check(!RemSoundUpdater.VerifyStagedRelease(zip, signatureUrl: "https://x/RemSound-v9.9.zip.sig", signatureBase64: null, log: null), + "a signature URL that fetched nothing must be refused"); + + // A signature by a DIFFERENT key (an attacker's, or a tampered download) → refused. + using (var attacker = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256)) + { + var forged = UpdateSignature.SignWithKey(zip, attacker.ExportECPrivateKeyPem()); + Check(!RemSoundUpdater.VerifyStagedRelease(zip, "https://x/z.sig", forged, null), + "a release signed by a NON-release key must be refused (the hijack case)"); + } + Check(!RemSoundUpdater.VerifyStagedRelease(zip, "https://x/z.sig", "not-valid-base64", null), + "a garbage signature must be refused, not throw"); + + // A genuine signature by the embedded release key → accepted. Only producible with the + // on-disk private key (Ed's box / this session); elsewhere the accept branch is noted skipped. + var realKeyPath = Environment.GetEnvironmentVariable("REMSOUND_SIGNING_KEY") ?? @"D:\Dropbox\proj\rsound key\remsound-signing-key.pem"; + if (!File.Exists(realKeyPath)) + return "no-sig + wrong-key + garbage all refused (genuine-accept branch needs the publisher key — noted)"; + var good = UpdateSignature.SignWithKey(zip, File.ReadAllText(realKeyPath)); + Check(RemSoundUpdater.VerifyStagedRelease(zip, "https://x/RemSound-v9.9.zip.sig", good, null), + "a release genuinely signed by the release key must be accepted"); + // And the SAME good signature over TAMPERED bytes must be refused (integrity, end to end). + var tamperedZip = (byte[])zip.Clone(); + tamperedZip[0] ^= 0xFF; + Check(!RemSoundUpdater.VerifyStagedRelease(tamperedZip, "https://x/z.sig", good, null), + "a valid signature over DIFFERENT bytes must be refused (download tamper)"); + return "no-sig + wrong-key + garbage + tamper all refused; a genuine release accepted"; + } + + /// The streaming password-strengthening walk-through (2026-07-27): an EXISTING weak + /// password must be forced up before audio flows — the load-bearing bit is that the streaming + /// prompt runs with requireStrong, which DISABLES the "unchanged password is exempt" rule, so + /// re-entering the same weak password is refused. Pins the dialog decision the pure Critique + /// test can't see. + private static string? StreamingPasswordStrengthening() + { + // Streaming mode (requireStrong: true) — the exemption is DISABLED, so re-entering the same + // weak password is refused and only a strong replacement is accepted. This is the bit that, + // if it regressed to the casual rule, would let "Games" keep streaming and defeat the whole + // 5.6 password raise. + Check(ProfilePasswordDialog.RejectionAdviceFor("Games", current: "Games", requireStrong: true) is not null, + "streaming mode must REFUSE re-entering the same weak password (no unchanged-exemption)"); + Check(ProfilePasswordDialog.RejectionAdviceFor("kettle9tiger42moon", current: "Games", requireStrong: true) is null, + "a strong replacement must be accepted in streaming mode"); + + // Casual mode (requireStrong: false) — an UNCHANGED existing password is grandfathered (a + // visit that doesn't touch it must not trap the user behind the new rule)... + Check(ProfilePasswordDialog.RejectionAdviceFor("Games", current: "Games", requireStrong: false) is null, + "casual mode must let an UNCHANGED existing password through"); + // ...but a NEW weak password is still refused, and trailing whitespace doesn't fool the + // unchanged comparison (both sides trimmed — the App-review inconsistency is gone). + Check(ProfilePasswordDialog.RejectionAdviceFor("Games", current: "kettle9tiger42moon", requireStrong: false) is not null, + "casual mode must still block a NEW weak password"); + Check(ProfilePasswordDialog.RejectionAdviceFor(" Games ", current: "Games", requireStrong: false) is null, + "the unchanged-exemption must compare trimmed (whitespace-only edit is still 'unchanged')"); + return "requireStrong refuses an unchanged weak password; casual grandfathers unchanged but blocks new-weak; trim-safe"; + } + /// The 5.6 password rules: the strength critique (what the dialogs enforce and /// explain) and the derivation choke-point refusing weak passwords outright, so NO path — /// tick, startup auto-connect, headless service — streams on a guessable password.