From d25d529e8604c5d0f9db3032160267624f2bbf5c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Thu, 2 May 2024 16:19:55 -0600 Subject: [PATCH] Extract functions for service worker usage, and add initial MSC3916 playwright test (when supported) (#12414) * Send user credentials to service worker for MSC3916 authentication * appease linter * Add initial test The test fails, seemingly because the service worker isn't being installed or because the network mock can't reach that far. * Remove unsafe access token code * Split out base IDB operations to avoid importing `document` in serviceworkers * Use safe crypto access for service workers * Fix tests/unsafe access * Remove backwards compatibility layer & appease linter * Add docs * Fix tests * Appease the linter * Iterate tests * Factor out pickle key handling for service workers * Enable everything we can about service workers * Appease the linter * Add docs * Rename win32 image to linux in hopes of it just working * Use actual image * Apply suggestions from code review Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Improve documentation * Document `??` not working * Try to appease the tests * Add some notes --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- playwright/e2e/timeline/timeline.spec.ts | 102 ++++++++++++++ playwright/element-web-test.ts | 4 + ...image-in-timeline-default-layout-linux.png | Bin 0 -> 37770 bytes src/BasePlatform.ts | 47 +------ src/Lifecycle.ts | 7 +- src/utils/StorageAccess.ts | 132 ++++++++++++++++++ src/utils/StorageManager.ts | 98 ++----------- src/utils/tokens/pickling.ts | 88 ++++++++++++ src/utils/tokens/tokens.ts | 10 +- test/Lifecycle-test.ts | 54 +++---- .../components/structures/MatrixChat-test.tsx | 14 +- test/utils/StorageAccess-test.ts | 55 ++++++++ 12 files changed, 435 insertions(+), 176 deletions(-) create mode 100644 playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png create mode 100644 src/utils/StorageAccess.ts create mode 100644 src/utils/tokens/pickling.ts create mode 100644 test/utils/StorageAccess-test.ts diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index 2ca507fc9ec..60aa1e2a27b 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -70,6 +70,22 @@ const sendEvent = async (client: Client, roomId: string, html = false): Promise< return client.sendEvent(roomId, null, "m.room.message" as EventType, content); }; +const sendImage = async ( + client: Client, + roomId: string, + pngBytes: Buffer, + additionalContent?: any, +): Promise => { + const upload = await client.uploadContent(pngBytes, { name: "image.png", type: "image/png" }); + return client.sendEvent(roomId, null, "m.room.message" as EventType, { + ...(additionalContent ?? {}), + + msgtype: "m.image" as MsgType, + body: "image.png", + url: upload.content_uri, + }); +}; + test.describe("Timeline", () => { test.use({ displayName: OLD_NAME, @@ -1136,5 +1152,91 @@ test.describe("Timeline", () => { screenshotOptions, ); }); + + async function testImageRendering(page: Page, app: ElementAppPage, room: { roomId: string }) { + await app.viewRoomById(room.roomId); + + // Reinstall the service workers to clear their implicit caches (global-level stuff) + await page.evaluate(async () => { + const registrations = await window.navigator.serviceWorker.getRegistrations(); + registrations.forEach((r) => r.update()); + }); + + await sendImage(app.client, room.roomId, NEW_AVATAR); + await expect(page.locator(".mx_MImageBody").first()).toBeVisible(); + + // Exclude timestamp and read marker from snapshot + const screenshotOptions = { + mask: [page.locator(".mx_MessageTimestamp")], + css: ` + .mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker { + display: none !important; + } + `, + }; + + await expect(page.locator(".mx_ScrollPanel")).toMatchScreenshot( + "image-in-timeline-default-layout.png", + screenshotOptions, + ); + } + + test("should render images in the timeline", async ({ page, app, room, context }) => { + await testImageRendering(page, app, room); + }); + + // XXX: This test doesn't actually work because the service worker relies on IndexedDB, which Playwright forces + // to be a localstorage implementation, which service workers cannot access. + // See https://github.com/microsoft/playwright/issues/11164 + // See https://github.com/microsoft/playwright/issues/15684#issuecomment-2070862042 + // + // In practice, this means this test will *always* succeed because it ends up relying on fallback behaviour tested + // above (unless of course the above tests are also broken). + test.describe("MSC3916 - Authenticated Media", () => { + test("should render authenticated images in the timeline", async ({ page, app, room, context }) => { + // Note: we have to use `context` instead of `page` for routing, otherwise we'll miss Service Worker events. + // See https://playwright.dev/docs/service-workers-experimental#network-events-and-routing + + // Install our mocks and preventative measures + await context.route("**/_matrix/client/versions", async (route) => { + // Force enable MSC3916, which may require the service worker's internal cache to be cleared later. + const json = await (await route.fetch()).json(); + if (!json["unstable_features"]) json["unstable_features"] = {}; + json["unstable_features"]["org.matrix.msc3916"] = true; + await route.fulfill({ json }); + }); + await context.route("**/_matrix/media/*/download/**", async (route) => { + // should not be called. We don't use `abort` so that it's clearer in the logs what happened. + await route.fulfill({ + status: 500, + json: { errcode: "M_UNKNOWN", error: "Unexpected route called." }, + }); + }); + await context.route("**/_matrix/media/*/thumbnail/**", async (route) => { + // should not be called. We don't use `abort` so that it's clearer in the logs what happened. + await route.fulfill({ + status: 500, + json: { errcode: "M_UNKNOWN", error: "Unexpected route called." }, + }); + }); + await context.route("**/_matrix/client/unstable/org.matrix.msc3916/download/**", async (route) => { + expect(route.request().headers()["Authorization"]).toBeDefined(); + // we can't use route.continue() because no configured homeserver supports MSC3916 yet + await route.fulfill({ + body: NEW_AVATAR, + }); + }); + await context.route("**/_matrix/client/unstable/org.matrix.msc3916/thumbnail/**", async (route) => { + expect(route.request().headers()["Authorization"]).toBeDefined(); + // we can't use route.continue() because no configured homeserver supports MSC3916 yet + await route.fulfill({ + body: NEW_AVATAR, + }); + }); + + // We check the same screenshot because there should be no user-visible impact to using authentication. + await testImageRendering(page, app, room); + }); + }); }); }); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index e67cca6ab82..2317978898d 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -33,6 +33,10 @@ import { Bot, CreateBotOpts } from "./pages/bot"; import { ProxyInstance, SlidingSyncProxy } from "./plugins/sliding-sync-proxy"; import { Webserver } from "./plugins/webserver"; +// Enable experimental service worker support +// See https://playwright.dev/docs/service-workers-experimental#how-to-enable +process.env["PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS"] = "1"; + const CONFIG_JSON: Partial = { // This is deliberately quite a minimal config.json, so that we can test that the default settings // actually work. diff --git a/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/image-in-timeline-default-layout-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..dfc55550aae6e92170eb26198e9bebc524968750 GIT binary patch literal 37770 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%0DTkin4(jNd7O585NI-% z>kp;6%hMuF@p4r)teIRszSehuHZ^ zy+NhnQ=x`*o_FNY_T?jQ0~a-k#jbfEDl@)p@<3%ykopEgGXO(PuUeO!Qt!C5xhW}S zz7f_Yn>TQMxm%sEms%0aka(}eX}8I~(&RBfM0NH%9}>z8&OEom8b))3o)E!zZ_kTf zGkHk`R4%zh+Ze@a)Y<|E{Lg{YsiM5z+3nh2qCvMfJX(KYIFR_hE0l!WdLDwWpnBK& zVEHu$DZg-*1a88?Dmw<5psq1J+CVB_m{F>U_zY*^LktYV>+d0Xu|f{K{s$|)ta=wX zj@#31lki!ugN14eQMpD}CF&d2RKQo;T%K5{2^_NfKEhpIlKq~QKd?a2oauUO>VCY! zOsn`REHjfLh0lqSoHY`}L0RRbs)v}EIbOGdIIlJ0QI0okH;znFf2m@Rx;TtX@ALQ8 zumv@zrQv2Fq0P;uJyqWe5Sm({Q0ShcpXVM6jcjU$={RkF8Xp@pXJ@=vIZov9s(hZj z$jQ#k5KWq=r86;Fp?33#Ez{6f-=KgHX%7>)S#fg@u!z^{)ZvzMsci!TPrL=(ZZfXR zHIb?E5dU0cDpS+%?U`y9kM0>0&q-woi=FApBqKft1NhB7`;DnDfK4sa>ynwS+-;Ab z5E~oMW&pK3q<26Js=ui;Ra%D^W1b(b>a`AGyKIM4n_CY3>xxfQ6t+_Sr>g&iL*Tf& zJA&eJ;U%w6cjnj{EC8nI3?>x^J{-brhoDd5v;q>k05#e1pVm6s5CoG-cal|O+)EaJ z{zqI~%A6J&l1&XR+a!TtnSA)J5NYtkl*82S{2&J1tQnx>3X8G8i{lNsUy9-&^mE%R zQt~uVDfgML2&7_iULH2x@G5T1v;o#dQa+!i(fPV{gQxgd%rSeOZv3|&r85*s&vMSRb z!DVnMtEsO)+{nLP8*WMg*cKe6kCB-Mx0}Nop~c1i;LClQ*0wf)?LU0)OPve4xxN?% z#OXgMrOV~ICqf<1=|P?5&Kk^2rFKBjm&ic}cR%JSR2TX$+_)tn{)w8$tffy(~TQ9JPO&C!}4&@(ndY02b_@W8uBAKG(<*39g3kTzR`6&w`A29k|3rw`;Ny%7R@5pr^8XJ>ow zs#n+ME)CrLD3Q+;XHxbJ7HQPB_9~fiJMTU93nO0H7&n`3aEV%5Gj{W%{#stn#$&tu z!YXz4KEQ?MP*!%!*>HE4!)ZQt3d9ru2yi8g(e_Ex!y#pmf?>!-g$C_^U89Gb-or%a= zWM;Di3;cW{mS4aqw3?~(4c-u!&hl4xz@G=;nOuWDVf|4dNK$69cb8-yN#2jLyEG-3 zG1?j$j{o;g*{XZol~0Z^EKR4QM}tzJjd1 z{o)A`1E^o~23tief;=@D6cHdd$-F6Fn>^7yuce(!6pJOsbEe7+=o~gUGUB~nC$d}g zl&#P`JLUiigXdf(U%d856@SV5IKeLgI`R7ChYM5{03??ik32J-_$3+OUDmL-*r!qN zC?Lo87@sj)ZV3rTKFaQGO#c5wYx|2Bg{FVZ~!+FgY6dLN(Xk+Xcg8x=+Y zcZ6Yzb6l|o6+M4)wd=9L6qlYc{MQ4ZkLZbQ#3F8O5TWCCT*N_Y|8VJ~R!<7MJBhhOftdk2WCa#agi3)MM0L3L|jYB06Fdyhp33zuZT z`z6_Moe1`Gt~2dK3m70sHu}%EgzRxZP*r0$318$F+?bsjRW?0bMIa=Qq0fln^KCi> zQck{_X9@(z@ta)lCM#qQEzL*h z={q>(cx)oBZdU0#F>Zs+CVjADq|e;0h^JgN3RB{iLv7AM>2s=SlL>TPL`8*L%5eB3YSI-Y&4MyT2Qq_R@wO+clE|5QuGHl0L@GCYC)TR z+zzl5%gx3Bt5lf5(U_QIc?Dk)vK+>tbTu1t1=b^ib^)4fpq0vPpK5v0A2@EHn%~3EwTen zfN^q+hDbG<+r*+*CiZVo$ygS0Y#;?H zwSk`ZP!5Fj4VQ9t&4#l0y!+5x!7bBF~7H5y# zO$*cVnj|q-MuO97MkGaTeE;C+ce4r>(8s=8dDJ5mV!ht$08C|2Ijup`UWz@l^QLuI z!DxSH^ZHJW+b$_SqZT46z1W8^yFb1QP|gH@s*NJ6vWHYZfFhkB$pvpwQ4vy{(8Gop z1%}bO3+sSF6C$)d{WIIHgMP3XGLZvoE*A9ki=R^Qyf~Hx&Y_EKfGCSlJ)-r&IZy`$ zJcF1`C$s92%0!y(Ky~N>MiB_TFX%`tLxa!XYGa9cOaOW^3 zo~yV2d^AZ`>VAv@^osqcR?yS1YJeR@&k!?MK)=A^*#GJPzZ>(-wX4JWXeA%qODNRU zVH2`9kz`e(zs`zGl0X_!V?z;d)pT$>#$8C|FXcNw zEVrNMF%oXUvIMr(IJ)$|?(NlstkHI5*DRp7!Xuloh>;Ocy~7o9B!j7vb&4!Nrfk7? zirgjP?5y9`(UGvDIelop84CfW>Cpl?&Az0mwKamO-J77Il<7s_2&xKpDlA9hHQjDp ze)w^e3J||HwGb5%JDi01*qh#>gZG0%+AwQOzGN6HSJeraa}g0Sq$lF7tcSZcWYZofYw@?+yjOBzZ&g3lAeBtA^Oy#>XGv;~PC6FCM5p zj4RlHC$jwHfSYV{B}lR7D*Tng#9{)ikV2!x15gaX?L)P_7PSs61-SVtZW~&T?=7QQ zx8KbB~i>vG20_uCfH=1I>}tpV%J#47oNh7zMk< z^nLG4v9LSyySrjH?&tArPNdf;=!@${)6a11zpt&W32y@xupG!oAO|Z0fB;vm@wf~% zxE;ytiaZ+sX>&lvTu>?q1gq-+&V@nDx61c)4?(e2&-%3 z-m7XWzk>-sZI!By&Q2S!<-k_|R6^c!sL2OtEmM)xx7r^X7e@nXKe5+H$pGK2U9M)b zrhjOF%&3g93W1G{A=q>xrO4&xC|jh73EX^3*&o^_cYAwuGI!3|vVmYiqa3db3A(Cf z-4BHWB_-C2{Rm-6lE%g;MmKg{OY?2I%JauypGQp%w=Gp}8cj8z_vd8TLMwoH4;x5B zb~J9?ZKG$p_{MqBpspGgz*+y~)G`~)ujqU#MDu#UN$oNvnV6x^PCDM&`fT;^6>8M` zU#lg6v{;(&BJ2EY`@vlRv7oNJxZ5Qm-CDfNVRyTFc3Tb@FM?*Ns}S3j-q8XRz3EC5 z=`_z`KvzfPhJ3s2QlC*yhBnHFZsKI$s*fI>Hea^=o5? zF0`?j73N9x);#J?Tj>U4?aJunnHgs*rzQ=Y^9FqaicK~$TR3jQ%Pg9zDyQr8l5E9M!&il@|guZc?XhX zKiPtsjQ+%8Ga57m9JBrA#JGGXQqr|zn(A5i0MZvb=d~uXE}u2u(#8sG|8R8>Yzgs2 z5}V>B1t8glAfYUs(YZdIG`tTS$;Xb^JLvHz!~le5sdUoGa-vANg&qt@ zQrF*36!InWdwl;bPxZ{A_+t?}fN2uZ>G!~Ub!@F)c-ny=VaTMCSahbDoRascmLI9^ zFjnCPix`x^<5VBV{#0p_=&ybeMC_jU2Zng=#_g5tm8OA_(ZE03<1!AZJn$U~e$D%h#4#ZJtPaYeAogw^66zBN~ zRn`s;=^CFQ<*Q6oK(_$)lEoHJe}?X^tOS&oW4oP@Py(^iaZ***bZSJGw|=ON+UN#+ zno~Z>Dl=3J?8#}CF5U3~g2ag1uUv3~V8BB#HP8B0!wGvqN#=b%=l1zxpB1;=NpjY# zQCkxwCFM6KxJ#6*(2e(Ot;gA7zE!R3aoP+kF)^|8=sFYBC ze9+MbL?BQyEFLm7Wgptj+1S~Y^B_Dir`@hYs+~{YYJ*}9$Kc=~O8xiUoe-8Xrz9mYIAF~}^|#lY3q6Yw%Ut~*rMHa6n!@;D3{{y=jUnuab> zArLfkO_L^_))@Z9%AZt=O z+4a2Qu<|;i0%a3dym-Yr;etv7KKRmtd%(fj9fH^+s zRB$|z*=3pGO`W5u57@$H<+;a0=EV{zvC=pED&(Z#xYdpXcq7B^VIB0#6E~p9*N3hG z#X8W%CGeWvA@dac>iz6B=z1_O%1`THvP!S~=?{8MA>5=u*mmV{T=%!Ubwl(^F|R*a zbu3q_@p%v))50X2dSPXy!+$}0Y;y(w&Qz@(7n|W=wtH=v{rYSKK;ZiesJmFtpZEHa zdFYz-jgQB$WQvsmHrj9?_5J?h4M1UcD^q`={dUR}`|a1@);opj{|;f_r)<>`|ze8**41n(MI&xtjpCEow3D-d&9bci#m)YUYdl zuq_g#$eaHQNV+1ZB=KZOh4gnnZoNCz58y{J98mw-qhH3q=srFzjYGF+wp#*nuVtad z;>Jtk@w#h@YP!q5xjj(4y%#<0xBuV)eGecm zKsF`&gXUTy_P_Tsuead;O$z^y>FNK?`Z{2j!atr3xDl&5C1w(Rt+Bo0tuFqK^_&>P z+o-6(88IjdmQi$(agltF#q~$3oF(a(4B0jLaT3lBO1M^4l3@&54EN-I()8QMbMS zr#~nS(2OdG(W_=XjT--6alBrsrn?EWbIp+%7!YJ zc%E!sSiDK<$CUYFXtq!fJJBTdypps~mf{;M#BhD0yoQ^Pc_+m+{KdLN-%qNp z+@mXd{*92F;j!kC+oa=II$eGhU6ryneM7b9Cm#RN0u=Iy>^pjeH@4hY!&GgBVeX)Lnf1yM1r(x&z8ukpDVtp0OnT!_n6!#+bAQ`-f5>(?hBx$7J)F@0$o-6M$x z-%74^_uKZnk*S158+5t2Og*>s6*<@_U&&Lhg>=gLpVuEgygL~EKuU$H zbrAXS-8+sB?-rIh&W@T>YW0p-{bQIV6I-*;U4CXpS|^}pa`Kw!?N$51XXInSqHct6 zgJ?LO5I}xE&Vddj^1OUJbakQ8aJz?Fv#Wl%cO) zQ>7bV=Ba3~^zd==qiyxIw6uM!Rld;Eb!*;m_aEK_o>v#$hg6(8%hoO(`S=O8W>__R z9Va6eaS?xhvl**eqAsm{R@Z{}j}&{h6y&r|E8Z*d#z_xu(mc4quGj0LcLy@4$VbX= z%Vn)m8}G5Aujr}hG;Ky@L={YV5Z<`A3{hyeEyuPlsMdo`DN}v1rV^_Y#JWCm9 z&O2gOFK`~Fxel0bOMKF+e@t}3-e$_OIFiQrHgfD>9nPmX>GOKB@OS<$S%_EEwCQhfUOXtvzqq{IEpuRE4ZO$paT zv#IBsvbU#G7EE^_#66r+YHE(tW-HN#!OX|=jWty$5bKeIjx;(I2y;3&E?cKj2At$K zQIbrUjTVV`uU%tQ^FSX->Iz{+HQO$9N^m>2oBNB+iRFZk-C9~(<4KYIFlKTDRlFPI7H zNS1t_?ZUqK+YtYX45$nqq~PM1x8XW2B(cB&=7cwYpq-rAqxSFRX~X6;k0G&e7wTuD zB)^KI!gqcj>@q2Oko+CCj3pDbGV_2>vj^3EN&=hWk>2WLO^eZC&;D)L$`cujd;nRI zl(1g+VvyUuP`o!8H=AG6g!};WyRne=u|LR)S z`h3t*<7M@L^LgB^=(5`6td}4joRg1y@?SiD&X^9~;MnX*z%+YJ?W&IY{rLRotG>p0 z(xG*y?-E7ozX3PS{T4?Ld5CIY?q)(ZAyoC`$)!5#al+Rg9u@md`u=*y=jfuiG$#N4 zo5_>6iqQo_&)1ITf7dJJ&WwFUv~3#;&SF5iiQ)np(4enUV}r!|I>}kyU(49DoWY@n&QR9A~s}U_Yc1NSo_|y_VwB8)2V5_bJq5f9aUou z!*%4DbvoO8%NuV0FCQUuZ<*X}A8OuVg8aVTUzMevS^PHg;$?@v57skQdJM{hf)DJ8 zEWNSc?1M=1M2QY8#7l^zN}dOQU{}@QivP@&82))V9foYtd)(Tt2dZWMGI?}s9fLP` zigk6?zsEo|kID6&pmW<>=hnCPaS2ncpT2xGeDPXKJo0l)Fta2Ix0Im@C51T(#b`f9 zuw8a~e`=tKIF!Ib_H8X6X%se-721Swwm*>sNu ztg2(^yx(PEKSvoRIeLq*iuRY}nu)NIZr@twpAAhQ(O;jjV0rle}icg+|(l8xrDW;p>#KVMFm~D8-B6D!^i1rIzZK>UR`wdkmaO zwqdyc8S`%n$9yY-M-l*ur@9v@&Dk&6(^dW0NLS0YY3&1Y{NA^MF~Q}!YfG?uO58T1 zDOOUIk5LKJ8i_D|`p80DCGt~n5L;i#3j-Wt(ei(f$`8AT2zB3tw_>6ykMuNn5YYOzL^r|fHKjHj2cP-7%L!jsBY33=z;zLz);kG7@eDShJza@FueOB3#vy*pJL<}sW z4DL?Q^elCcmt<8(97`P&j~ZsR(0JZ8$B5q!N|S{YBV^m4dA`=RA5p-IO@gU{PFLzG zvNNAMeJXlyH0;AHRy>AA@FIOUHCQM_05xbVkekv6w3i)e$yr36Jl}HEFy^7L_omo} zJ5%VM<3#EI{`pPHou`_35+NlV*hBqyJUX~z>0voy0$@C(8$-?p7o0{G$)1w<*%wTu ze$|eq|Lw9)^?^D5rMT;LC$bPV0vbX^XZk=;5WW$B@R;HnZt1%Fnw%bmf|`45Kjo`x z4X@GHHEwVxjt|mCGeTuCbkwcbJRR)sIUZH}hrVYqHIl6i{Y;e zOic2VBG1~+?^Dd;j?f#t`?Yo=&zU7qq$K-W?GzZrLOWjbB5`B~B$ht5Aot_adS^KU z>b~_7V@=!J5t~;Ss_3G#u|iL(mt|YHQ_GQUrFdE!DZ(yQ?4Y=?i3;~t#s~;~cR#~gOqf4Nw#ycS%X7ly4or z`|kZ`Y}Q^eW6Uwf+MKPm%tc>GuN$y<8zkl~*#1>L&RhG?R|}=xx%goL4s{$~j@WXy z=7R0^dK*Q}%Tq#{mb`lGf^b1yghb%CBL07a`Gdh6M;F7rVts@7srbPgvF4Lz*Bv#- z^yKCHa33DZC6y!oZYMd2aN9aibL3co+zm-@4{B8u#L;|r`ot8x3(Awsp`y@Sj?-Ga z7`3Itj=PqsN%*=1h5l(DOSfONwtr;|-VYso@T|TV>_6d_7L0{XqegQLHOYF6 z>?|TioIyUKKjLPPc-=F`6NsuJtU0v#==5K4`TCe4W?Bk`W#bc5g~|w4wTBhMde>XV zC1rT$CE?$FphM7tSp(>vq&@?e?Hz?yIzpxmc9J} zDoAA!qyF4i&Y^)<2dj0*R$fE_B6=wnqfrS6TZOUhVuM^kixyyl;9Y7xAb5{&)3n7F zO_?f4*T4sJH8y9ZX3>qJ(}jV*N=W3>C38SaFZ?M!|IrRd*DQoTqq!^7T|7}Hp2I2Y zLI@_Mf+k0tG2T$7<9#}+Yk^e{Ul&T1jPxnTg=9 z!daa!`?#Rk%0rIioLW9TNhztHFwi*Q$fhzVuWUO=qgvglKjyu5MVQ(eHmXDa!RI5F zQ-p^-V(F+)l*}Ud)ag<3f$N}5hYO0Xyl~~Zqg`+M3sVbI>c58`f!yMJAk=_>GifiK zLO&PINB^pW3VFiOZ`S00pLh(xRWn=ga-qLhYgKn4%&m8oZg;sY+TfVMWO{hW0_n=o z=@A6}2sN};!38t7{^Bx3AL5TXkR0~ld-QGXBtbXU0;@{ry0p%!i83t6ZOi0ttD4fH zgb!XIvLdLPjX*fd38R$weM6M z;{iu%@%TzngHo9gnSCC6y*6SR97Qgzr^Ujvg)aigD6;?gu416Dw&O-cA;xmCx3mi; z9eGHWT5D(qVri}^c*EF}3bXET_l6jgosq2-Y*hFV#zzCRdJSHTz%2g@IEC#AM5uF! zcDLD9R4C%UV5=;U^j0Buvxl4o-=_T7%(B3M+csS^(R^a1ThDnPOz^=gBfOZ#5Lldil}`~cl4E2b1q|l& zKhutWv@X${bW&mGGH<`uZ9~o)YDaPLbX^j?E&1&u0zH28umfwGee?0#1iO{ETx?GMhphE7BBE0xWigxJoH z+(3u{ik>0pWQ(^nZvW+TqNxZq7<{1rr6x!i%6E$yw(}UGUU@Y{Hr;1J4 zziS`=$Y|M}OCQx2@avIH+9^>6pPOtxN>gSfbtE%AwwC)ghxZH|-1* z?yr)=qDO7A!5=bxE)I)gQhzGU&=yKF^K61?c~?@d?mQE(J^ z=2gOrrTcp!;N4E1q;$^2QBdI3?;L@@&EPYTEFJJ4y0|B{IkW~5m2RWmbp9xB!1XaJ zzy)ct{PRaAzqnRI7G!htMlC5_T-O3KO&`)nHzC-Iv&M$u$Ly4|)N<6ebAeokURV@f z6tul#<@ZkjU)PshFmaa$#P(x_|p zyX2Mfu^!<}LYx__&78ZFrTgOQlt#0(mp;0Y_Oz+)+xg2`I&k)?@IarN(w0CZski#% z>D>7vH>_%a)!2CDP|UQy$Oz1@@}(Jny07~Fhc7MOycQJhuVJ$xl`(3-!&*e>-Gm9Vl)<^GI?NEa#+^+8P@w? zYWZFZ{So*+v|`DBJcuVV?Z%X4P%-0HxYOc|_)K37uq>pBVL2z98b6n~v`z+Ph4_@Y zjq$LA)OWlB@geB%d}deljphTil*uv`NUOY#Dt>Dthes70d457XViA*m;^@AFyhNk;(p$;ntSuMS$4s*mg|b7`(Cvs1e;KWWqwKJHYnymLR4`2YbVWI>uEm4qr2{rF^G_q^n}>u82v}|R~9;a zj-i2C)9TFd02XgoX@++3v!MAgI}H3Jvv@=lRx?&g#j!ES(};XGC}QFUoS)xEcVc}= zl<3V~Z|aan0VQ=vG^1QL{StN4q^p_>X2fe_maDDbWV{y|M z5PgN+&`J`NwNS1_w3Ux0(k^KOm-E0nLn0XE;^;G}ZFX%zV0`>avWJ%wwZdUuRXi3? zke-JE=M!hh$HVuM(THW(luoV@aR1L2=FkoRmM2ZaT-04^mPkZqF8plBP2Qm(Zzeea zDQ_m+uM|O`Y^f0)Amux?=Qd-ut; z<$d*;Lo4Z6e>yw=AbX;rHvMkrc&jPjAou7(ipW~Az z;Djd|LY;iKM$(j#_b^=W84GHc#Z zU(hB?HM4bto%?9yDZpB9lw*FxY6ZfAQ#_-%=%N}5|%CQ6o+>g9wOSlJlI43$e$1YAlv#Hi>FZmSxL_;Sl==o zA%tB3HiBd3f`OdRH`KvRg}UtLCT;gvHlQG#OHn1@NOv6Tz|69bE0pKMgefTE07hB| zgrif5qFxp++Vayc;Svakv>7n~tgkI{p$m(67{5WtOmjgS=X4vMS`ZtJ37*gTt*xNk zX33Z5yIN<=bHu?5V)DeQVseP+uBB0Xgt(kaWQEmSl84`KtgHT%zslMbM~-;6I`?E9 zQ_H}2xeon^7GIT{TPHJ{t)U#4OCq}qhSfF1{Ab1R4bG7|tzgs{a}-p! z)Zhy@e5CN2Ugzp~07{yr)w|8dcx?a>0%mBC=GCdP%+b zxHOZ`jdZwP#QF=9_1JgYZChL_&`FdbIdgXZ?hN=h<-L6ZDtfzAM#QIsl#$av>&bDV z4MPa4utE|kA5-oZF#m32;ch%R7ShmJa#&6iBO(qzd=x&lve)~2N$MfH=TgfhOWlvx ztGWc$Ga$(D=^R}|?H&EvkgMRXdFzsoC&xPG4BdFOayslEBr^e-<-@11E{U&;v4vK7csA3l>1FIFarPYKnnfPC*jI-E-{ z`6-trE^lKam_LKxqv1m<(+4SS0v=Xc)pOVFUw|g}LY;fCsR%2u%U9Wr)Nsm_{;43Q zxrm6PP$o+&>Fm_Z7nv>1j-!EBV?3g+?&n^DyZh=sRE+>W5|4l$29%!+9i$Yd2au^| z+CSd58)G$wx7MLPOHko@FkH{(^~gplaa*wH`-MvS5EB#$hm3+3MWcqpS_yj}QOpcgJ+EwZKJ_*$-hJjsq z7XXSy<)-|kj*`!Ne?(5Lxf}m`+4z9ilp`7C-YE$HW|6i)L2jr=PPH{i_p~q~CN~^v z&n3G3orG#V=>BaT(H(8;?w($p@_^%k@(LgrbLl~rljKb1AOZlxW@kP1nHBfTjxQS` zP0;-}AV&w}PwjC24bvaRG%+QTgk_3uXI2rmtz> zgkr&3e&HQu_X|9MN)7Qqu3nAVlw`UQjSsnvIr2Go%|{ZIqDuA(e&;4!(Ef{k{Te}i znuh|jzIg>xeL8z$@3F_|A9r{mEJ2tAhH@F24jloUdsIq-2@Irtl2ztBk~V77yji8q z3U~oo7FP6;gn69mZ1o56IZgbXM!WnBR;BQQ1P)3E0w|vTZ>TfzdD<~NlMzGaFR}I{ zBSHPRVy&*acXkWae*6tCDl?j{YTsKzIxTttJwG{}lbA?^Y%M4R8qV86Yg>xyrXVry z0W#6FE6OI4pj`eA|LF(^0hJ^#f>0E5`sI4bzS3%iw3sl8u2(wX)iuste-}on_<#?y zyEj_led>IG6OvTP97!Pg?V4}V&b>?7_sG39;ZUIAghuQFid_kO-L6cLW9b;P#rev% z8f78Z6ib#Ay~XQ_sxKmU7wTM}$f&2(7kXEQ{hw1}8(O{X2S6AsOB^&Jxa5CO65yZt zKx}2=$A}%)Q|fRQU(sBP`1+9qOb}|1q?w=2SYFMA)80i}F{w~-k zuI(WU7b?A1oUA*I*{ORm z0#-Y-$XYL1D49{N!b(Ztc9q*k>V7K-OqJlaJoinJ$YL)2wnoEjOly>wK+$r$)zP`B zrXTy1+w|II?VI|J-At^n(nJS}peo1~p;fKn2VimqRoxmd;#8bnC9j<3m1Ky53oeLL zEFB470{~KSK@mJNsrOa&Bz_t(&Kj_ffCE^)SOI8k8#5|xBsSqDbjSefWzk*& z?6v3-daW@h2!$v4il!jUv!eEbzmovFBWJvQjH%z>TbE8Q*{Ix2t;!6}2!VPS-C%Ns zdrHg2Sc=tG-zj;UH0vZNaAsyc3{)S|(RI=;l={1vo}+(gUVf)nNZLzhL*r?q>|DI5Rqvo+}P*)Jxd?m zf(S(k1@#UbaIr+s-y}20rmT$ip7m>KZ0<;bgXgQ#hZC*2-`YCE=u4Icdy_-h7Hv9d zdNKn!B2`@o|Ik?U0-(K45xNAJ8V%wSJQYY&`N>%;zI24u^mAE!Kzf^{QOP{lHcr0c zJT<%O1UJia|8-`RJhsLLnSL}Ql-VGA7pbBBXpr`Hw-4pE*pf@c(JeD+Zs}0D&P+jZ zDCUZv8OnP!96$y-SV&c7{<6=Wl(2y4ZS%*vLyY3`sO@-N&8vY&Ecoyp*ujsC>?cK6 z6AQFIc--+vI+?S36v+{Z$kflhrY{Y8JCbBy zuiRGUhvWx>R~ZgTh~`xtd=!Oh&z`Tw_vm|+=A{W|($H9g50py$0|cb8v`8~BMJd== zz|8V$92fcY6|IAyX;yOR$C27Z+K2=j*gc_5x2Wh0@$Z!BNf z=E5iMA%*_lv>0;l%{D8f_1nu)Jg(MDxB&!kkxFQN4Hec7j5n%Y0rbZc(By&nDvgHf z;*_;t7Ei&9EMM+-{$Lgi6}zSDHtWVveM+O!eqDm`?9I_`b@5v>Icm2Mtb8*_Qor+@l(YjXHLD%R1Jo*Nn?; zT*o0*?M zpTereO+-!^lS99&tV|W|U*;CiAebs3O<$1C0ybI*35HCM{A*zJg zf5NOUJmdHjyD}#ELKR|jXbVaee!t#)&Qx)IIwdcZR0mtYB^B{V%I2b}^7R6VNyQYLb8$zzq9P2c7!3V9E} zVoyqa$dZwXKb{WB@YtStj$M!2M}S>)eYotOvo9qSZO);{uEIFTsQs=;PZQ??vpPHm z*a{($7L4xO_;7+NTitJ^*QvY;6a3RIzgCzh58IhT`Sm}Ga((NIQx^&57+j+qpMIaR z-&e=jS-^AwHv zGnYYMol}S)KJ5AdS{SW%cJcE?ZYam8;l z$x-&!f^!YR+npInhaFVdR<%qSI$Fx7#_c&2BYfh}=4^cPen5Vk^hbOF@&)RpUs^rUm&AnOOJ{)7gw2lb{cKqQ1SK%r zxZnO5ZwM8XB2i?eFb=3+7$(+$njAD(EMdf0KeJ+OLk)u|j<8fI?eKo6?F!qv zD)D$q&Up_=H!zbRQ!vQElioTS`!^=U<3n?pq*uP1I)9vNticGe!jxSE3O96~!*Lwz zV?B$n@AVo6TDgSRJ&G7n4Z$&kU6ywrWrm9X`oU<=Rzi;1q_wOc`d;0WFq&>JzGrbe zc17W8FGISbrO>x8EB((20;LC`vqh)o|Ow26zGATf!ZVjmr*QyOO+I zXH2&t-ICYz$>67&v^dAHEuxf+T|~6iYH&sdxUCcLm`Xw#FP?-}mX?$KvzQB<7PiJE zZC}rsAaD-3n{5xfTuyx9Ee4kxf|XuLjsE-bBoFDkd3up`eJ29E<@T?6PcP9Af+KLy z(HY%X!Tl97z4^%$u$Hn+7#$G-OsyD=*Sbe4^Tui)8t7raKXsLa>1~;<-8r_eHqis| zB%w7h%luIK1+jCGxr)kM3RcKuxs z0B+OOPJ%Zv;tqpAPF1D8xAfrIbFt}lQh2^RE}h8?5-IjfL`KM7nl9Wu&&zNpC7=OB zk_ncaMi9#iP>Q#dnsK9Mcb2Q88{UuJgv_u1eqcc|ouC4Y4V z$*Y5}n71P9UZ<()2bv_};oA+Q=kAiH-=wW1y)G&Pz+Pf3mNAm9$Cx{3D86Wy+}@p9 z{rsA+v4DB=$(Wh4Yc$Z!6nXl9_%M!|%n^~&ZP{Hx{ww1vEU8h8Pad=p)SG?ZCLbGI z@~BS(Zh+1Q^pwMXu-e0lrrkr~FzDMZIbkC>WH9P^i^18k7Q#cU$(5;mBgOHCQ^hqp zsWcQ<&Eu9EYw@hh1|%{y*JvvxoJS&I;M3voXwhrQ7a8+J4KR$2%8Sy-n$^^A;WIqo z(#^dl2P?z7k%8q z64GF*_%UGJbn`_A(dvM8LR|G{n|>~LYl=Jgw4dX3(QLskIY13SP6GbmX}pmI8u)bI zZoD-q0i``k><}44D<7`EIJU3gOX7_O^<(Ka>m<@%=VoTa$t#hqAd7Mc4TO7d^8xg> zF^`FEFV3T%iM_>;Px{<7(`L5tn%pzqsx|_ib{HtZ!Oo~t>r?MLm7*0H_+7r-3lp^u z3OWgrUq}%^6WCG^TK)D{lBL7;@5CtH(aPVcz$@tqgI^!K{w7B&%0gN+1u8S+{qnMB-NEWKo-axDWeg_^n#5?QT<8ytWMKt_|oVA%Op_Yo+j zT2uuwrhk80-yN!|G3homO)}j$(<@hZ1P3Q{q9Qc3;RRRJ-aI{-xx42$ zAE2Svu_2uQfX2F{}LJmwL>e+HoUhm>5>*AEkvl*VP7&0iWkz-qZS z_BohG=5>_3xC%1^xX1fTk`8XXb^LXUdbR?qZ(|iP)vIzQ>mA9KfnFgHhIbIaDQwKG zUG~r|r$G`UOw)0GG=|)!K9eT*)e<4mcx^^~%1aS-wV4vkeNuD(&0SfF_30ovh>pgR zzt!3a!Y(gWftP~Dg3$M#D=OVDWhbk7i=8v9^-S+gy46i2ae&_N0wbb)CM&j=;hT}oOjsxtSVL4eaKL@>%L8VcWc>*8guD$4lpki2$^zAh8 zbg&i?()x6wngg~p#K?hG8Zk$|MJAVH<#w*V%5Pp@z%M6@rv{)ZO!8R=VWGe>8C$H) z!I4+U+48fS@I#?~fxBD}S3RB|*Rc0?A6=Hz4 z7|3sL0{S1J+CNpx!8{gR0Tg@DJ04l7jVd$Aj9J+aSKxM^@u-Es2T%)kh!k97$fE@A z3eSU(;t6(vRwGcks`u0OuU{fngDkQ#YK{qXSo$CN@qQ}(z?3DwYX~AF0~E=q8E11P zx%`9jcVy7A!;sz-)-Bo;bu03nH$`P%ozmi45E zk6=Ean%qZOUJ=>HXvbcGl{tkxYtt^Ffgagzr*UEe*=xck5D&%VC!lddO<9L&sIr>9 zGco&OG{kk$HLdKsv=wz0h#UFLpy8Aa7>KyFvzKdclt_HFPiZ?MC@Zb+EA80RLax3& zfz+!l+4fa2fz)uF3v>v6=?uo{vvwJkA|_zdtD7NB?^FspBO>GV)>>Jm=Vww3o&AJ!TCnO*wvI;Kp9UR(jFfio z-&bSQh5!Rv>pj_`{ty$dqW>s1P&jtKm_LfWA+FNC3|+U|1bTV%~?| z&1B69beVe4e@{J~=rtPrqU)exl@oW4(+%TRARO^sN2Fn%g1_}OovcL+^!+1BZVZkMLpr+{jv(cpL+nmcHxN^`& zkJWfAZjF5V#-oc2a3y^ef>PcY1UxeMzZtfStqrT#reojZ`&)+3UiEMP6%_En#3aY5 zzbXL$H%Gr4Mr|!M55&RXRqz7$gSR0nT%jZ~t@9D6Y~iBl{y&RY4h%YvgQ5 za9?K}T7l`wH`UJjzc1^)wLBmJ1zbBP>U10MAq!ZC6?X>(b~_dJ6Vi6epe*&x1C`B& zEJ5~~$N!vdPzk9ODDdPaA{;f3_0%JejV3+49QWF%C3{CROoW%=wZ}CQ2);=tj_rx) zY|k3_)5fQ^4?}ZViZTk-c4b}#jFnuc>QxRw(B8%HEj+g(w;Rkt&tteJ8e7W7aaS*; zm>NSkF6*LheeDPKBL?7UV^%5l0m|>+F3!SY@TYg1@4zt#s(Jq5n6!~jn;l8umk$Oq z0-)aFP+*sem}P=uL!RXKUHy&6sYnjR#?cOcK{TsT_@AM{Z3A=+PNMMDXx48i>0FT( zjzT8cwZo+_fCv<{w^TQ;EBolkxanC*G`HltCTG{@eQUdym>>nuMy;;3PmUbhCRtGEXEOmJ9-{1aF_DUX4 zi?LeJZB%x_Txp#{W&W5p~X@j6C<4S6-&=)tO<)j}YWqVMBb z?yaJTs(+G)899Mo`Q+=9qMyWGt&P-R#|Wri*QpFWmv=J%kC9ae)S`-woOwU~A0XLK zmrL$8>iR%t3YXkWPK_cWG6+VQGms9aB&S4MgNBOS?7Czx`6pYS;d-lO_M<_1v2;!Y zqWJ`%WB5;10$*k7r<7oQJkgu*5OW1-{#z$f4I+C87w8W}BnQT=TYPFv=dC(;7H%0-h)epA3`zRvoG zgo?iG!v^jJcD1paQ>K-*ywbp{-2Q-;IKvwLYItukl^$!B81)%Pj1mr_BJaF0FDYn> z&@QKidFwuM;y`Oy{_*YdVs$F8JdbmQm`d`pfZ6YAq+y%VUpoesfXKe{ctym%&4&~lyQ`YlY zmlRn`sO0eDQJ60V&7Tx@tG8cQY>_@)xO;@5fy61x9uc{y1Sp{#lYW&k091`f`*HoX zCh;BCI{_&K=nd!}XVjG^DXTg-;=h3hUY4LQ3-e%$1LeG$o2bUto z$>owGdY~#`YJi%!JP?8*zy=z%s}fEtLklclx?7nFBTo4f`FUqi2o2a(N|Ae} zeH?p&VVs=1e)|M8z|(o2|D-xZyFOa%JcRQLpidYcLqLKV2hWiIolUg+Jvr&&&`T9U zrHn|DUT>e7rNVm%CL{%5aL{-AWmY=D^D=;^=l4$I{pxp2G z2?maNBfN*J z;o!Znb1;yLqE6-ZVgBM{_b?br>#_?1Iw2EUVd2e{df(8E_9EN?sXH zLZgbe4JFvtSRB?rv;C_hIZo`G0LlH zrp$uwWX?iwK_wbOaoHO3PFQmVkV_U*E8e{1Uj|`44>e>yMy1fS&>K!!&VUJv5MfF} zD#n9-=bH7Ti#NmAJNzIG)VbgVLa{98Y}j$*_b(i{J?xR+WgjOC>0n) z;cQw~jdL~;U|+`1vi_>++&zWsmoBke11o9nsKiP?(10!v&>y&(E}02Nn?JP6TwbZ< z-u=xXogg_ey-w~O!yElqU8m(>xI^cyyM9^%uEiKG{bO6&DVlWAhr~f{O@_p8vpr&d zTO;}#Vw+kU68P#ik_q4Ft|kVqkVbBQHm@~4{gWO~fh>*xP|X2FWXgn5&rrTz)9eQc z7s5wJdwDdgKr0*Nm~T;A01}*GZ&obo?(=m+1uxpyjakkt5Fpz`LPocv;nzQxMioXh z_(N?0znt;s;NQ=G?k6_vi>V25(R@`v+zl|x;)-H2q`X>l?Kk2-KxK=%$t@AW!4pGt z;lKWffGmyH&{nV^73OzY+Oj`9N^Zpo=Msz(LjG-3l(0Nz|0i@LnBVu!!F@F^)6}D^ z=8<~lL6|uc4>fr!rr~he4hUL6O>DX}#XPzV+R4EXQbsiPLcNb)ck=iR=Yc48 z8purM8#s^9JV;oF%{ogqfQDZ=Lef%_r8et!Wdi`D7qm1$e_egGXaVZ$l|5;>w&3Tj z6r2bob9Z)Up@?bsym1X6aLw=@KA*w#J_1q&iwZ3m(uWFP3f~VKug7?{O&KrLnqI8y z0DYYJlwV#R3LHTgGy#3CneG?R<_BtL1js9G(12p*BMCp97&~>m-2VHYVhg}`C4?x|{^=Z;dfo9Xss|yeWzR@&z@ErX;QezT>!*5bA(deHH#Vz5bZrXQCVVF8r41-KB9+t(^w_$XX6zJRi{dQ#~oPgUGQoTH~DDe9|EeQfZ z#0Ym}&<3DG%z-2y`cmJZ%mLkGVm+NR~ryi$uswGuFOmF5Sv zw+m%N_IEVx8SWdvq=2%>1Vvg#k}T4!%l-{X`_PVovaOdNd-A>>o@U;J9F?Vq$C}fE zVXX|1bv{AEZ_(s?xDkaUDcY{0axUf>rm>;yUb0%h{mZN0TX}X2=r6r}ueo-Ow zI=o-CX=%=LHt+Y>EYuI@!)^o?7Qq6Hl%JoKqAoV=DG+{-PqQy|fns?jHqSlD_nD{Y zh&(GURiG3Q8TA7S;z9I92$wF;od4Kd7#;df!**{suqA1R-of4HhlfZP^yuQ4=tq9n4|WavUU$(UMl#peFn<-L=b%x)k9=;X0FMKw{0ZT%iDV!ln5>{XVm?M6e~!rbk_`jP1HZUpJS(*>Yh z@fK!A3&?J;7GpT|j0(O2(fM)u?cRQH2@70`HEFcrPn-d^Y)v|E>%YPe^fz4Sz*SJC zUXF(?9Uh?<^t$1_@>wJX7o5i&>7+hBYNZ6}4P~_~SBRyW>HmIgWA_gl2+=xV8$K?@ z;apDHbv2bLnY^2)bwLH)_Gqm4X+T?xX=MH{CD8uiQoWbRv=w53KEmq2i3Ar5;4HQPgJgImiNsnP5H&k!PC|qJ z%8?L6B?JYtBG*4Hq#Wdx19e?Q44;LlP|!(qY%V=14J!9uyyUYrKc+8{dqT(wU{`s; zuAZMq2poBXYYU|L0spA{sLO_^anjof&tu)1=L~|=u}i=@6@eO>$HQ@#=szPSO6JIb zuI;xA&1+^0=l^r}+>Vr*PjGb8!2=vb$Vm_;>DO&<*k!~EE@NBwjBQdEd{!okr8GxQ zCMfU!+y?f>!ky&7j(%rN$&`blDv>bkp#lkO~ zfXm<>#sfq^J^?Or0P|Te?jPqtMh7Y+Dv?QGWrQlJ2>2@U8d0tVt7}7*^+NXO_zJ|v z9}Mh-qlB@Wk-0E@f9Yf@M-U})h0p1~1p!wJlm*U+tB{f9zmyL_gceJik^cAB4O2%Q zo&qLw<1h47AnC~eYi$unS$^e3D(sQ3$_ zQuhrSKnAurVw6RQC9GHWTzmRI@p?S=(e~lUuOCsyf($v zs@1$M*I523JkP#&W8!*yF}w94u7KPpLppbL$D;N}xx3(vR1lT%v z_%j|s+ETju-aMi?ueRtOag}xa$@t|(;k!XwA+^keoI%)N4O`tC5_7`tyVEl%anGYR zjXUW@gJ9FK`hWkza9TURlDsz*dX2IT&w9s{)@&19;uhVFLo#mr=@_-lg_}6Ein^DoE+d_urNc7V6a*e#fqRltrfRil{#~ zt6d&;Ma-j9GLoKOSWwQFfdJOl3w{i%otkt0ILhiqSiTTKYCQWi>t65>d$Yl(KotTZ zJ+1$vhTrdgQv;NYY{&WOiU`~RZ<==I+}b%YIUo>xBzpG#jfOi<@9}p9ypX8uLwT)i z75k7KObFx%O-cj5;aQ=$*f%{6%|{E>#BE2FoO0G|pSODgzQadv%Q7-!{O%=JqKd@IZsq-Vnk zKN{-t=UhVHC;yJcgU)%5Y*7$K}AgmBvCaGB~OsY(f zK_DxXjtm%{`6f9Bn_ z!@s@jzx^2zC)E(CsCU{Rd4%{r`|C8p3N(S(q}qbRFZoLRL~CC!3*RR15E>16RQxe{ z@m5sHVBQjn41shZ&|?JZcv;omoBy-kjSNvJgvhdYyX~w-1UJOqOd&wx)JU~S)%b`f zM#?-(J^!`-qJnRGLd?(A@rKmm8g6by?8pi5#TXf;TeIG5o5Aq~uoL2 zU3ZhxlG2#xVe(rL;7g?1VZ~)KZK=nI_v>m9Xf%LcxWx-)T%n)%i4P(mPaM&L=eJ&KE+vStck&5l*(Yxs)- zb}{fE6PFGC6VIz5{R~HXc?=_(zd+UBxt66^4JSIQJn~UDe3367nVK0(l)k9ZwT`iy zKE79+U~P*d`k->|zIp1ZJBA6jTO&dv@Ju)1L}iQO3rFuz-w$hu-CYwn;rk&0->(mX zkjb}X6T838f{EjD!;FR^qfyU1_TBb(t@tt?d5@g^((`F_$t+_}0cQ?@L_Le*ASH^D zI^*hf->#%T1l#p21F_Fk_#9B4iz<{F^*yRLEa4D{8Btp#Q1MsSLjY3cp7*$kPGQz(03$%mA$vQvl&2FFC+oS z27$=>#|H;Z;8BlKl-67qnt~fF?=yBN87f)tDPO3G8p>4-eA;zMe|RzgJb9sa7kaI{ zPvcrqr;wx7<$|5yZ>wD-)Bme9nAgR=Uvr}}qaYD%UxKk2ZBDldPff_p`I}SYyQ6J4 zssn3*j38x>iKJZqq{tePMm-_BEcjt@OHgGj&Q%@yV^f!@`>0kieCOuUOd@#0@AD>U zd_qNmIWr_aSUK;-LU~Q$#dhj-bJ32avLG=(7_2P;tcSyg(=Vb$8!F9OJLrNB+$Hy; z(OZh=c4Nn?SPdQrHblf5MNhb-6V#SR^HtBIJ-3?wseiDn)F{l+)4I@r@w5 z`%GsKZ?W!>h#$!dgB!q zJK=MO=xI5j;d*(tJCd$BSi6sBf5#z3ELFNBBa@znV`;lc-8`mI%%b$pdu`wgx_-KV z_8@HF)A0Nw;FL+vn{LyZsADzsYK>%K{q9=@*e4GsHsyZfc+IDB!{P`pE~|Iv`O|Ci zstkN8D?D^!Usx*E6d#C-}*aiz7NmKPNM-uWHyy#XK~ zgu-6Y+xSGrBV(chnCXZMY_wM5pufXTz~d*M`TfjPeFTx@B@`M1|sAMnXBt%-{-Zvv!Ay6 zR0Ei1G6{6?Vb7bI)cH|y_F~dCvfTb&lni0=^VgAE-mBh*1myp&r5bR)IeYup`^DSm zO64`wlOa9i&5OPXbp$c^l9_6pLmZPyLPQPEyaH4x^#Z5>fO@?S%Y2 z4gIoZk;YeEV$dhDIt@p33K?q0KVq%-w$(Jud%`Y#iAh@8Qrw-gkQeQi>n2T)p%7;`9v9wplHBbVDIMLzIrsLi5;?M-7g;CT+%8L zD{D6Yn@v-2`ji;bv+tDL(W1haZhBcngM02&#)!wr=S4+?n|6=;w8m%DVN-E>qu!;Q-h-sXYk+#MmbW*LNDS<3&Vrp=Vd;T+O*^txD`qbp?msz3=IJ6v9Fn zZQ<^oxO5r#8oz1qH5xF116{gg7OFl zA^Hx9^)x#dv+lHBrHsMP6|SL+vkS}49?o5w$Gb7 z2TZnBLiy3ds5PyHRJ4Hvxd?SekY=+59H&sddipFoj zanxHMZjWdB+_)a~@$mE2_PNcl^$lyNcY7$L>wT|QYu)LZ;D-6*IhB;Ff_H%6qj=+~ zTV38LX}cndwh6QOU{m<)3$+4x<$PA5rR9eP z?mU?fc3JVF05gmQ#3PgbBBOrm&XAw!@YXio7v%0;bX4`}<*ye<{&SsoMHp_y!jx*7 z4Bvhmm764he~IWavBEyziEIh-sg?8H>J>P^5^M(kfiBqm# z4zoJ-d=6%zv~Q@~4tDILr{%Z*Z+Cu(0}HV=hQRfTz$Rwj8$}yn2LV*s^Z?s;z=B8F zO$cAkrTkekDelF{r5}E*EWS}qr literal 0 HcmV?d00001 diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 7150336e450..2dd9ac17cfd 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -34,10 +34,11 @@ import { CheckUpdatesPayload } from "./dispatcher/payloads/CheckUpdatesPayload"; import { Action } from "./dispatcher/actions"; import { hideToast as hideUpdateToast } from "./toasts/UpdateToast"; import { MatrixClientPeg } from "./MatrixClientPeg"; -import { idbLoad, idbSave, idbDelete } from "./utils/StorageManager"; +import { idbLoad, idbSave, idbDelete } from "./utils/StorageAccess"; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { IConfigOptions } from "./IConfigOptions"; import SdkConfig from "./SdkConfig"; +import { buildAndEncodePickleKey, getPickleAdditionalData } from "./utils/tokens/pickling"; export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url"; export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url"; @@ -352,55 +353,21 @@ export default abstract class BasePlatform { /** * Get a previously stored pickle key. The pickle key is used for - * encrypting libolm objects. + * encrypting libolm objects and react-sdk-crypto data. * @param {string} userId the user ID for the user that the pickle key is for. - * @param {string} userId the device ID that the pickle key is for. + * @param {string} deviceId the device ID that the pickle key is for. * @returns {string|null} the previously stored pickle key, or null if no * pickle key has been stored. */ public async getPickleKey(userId: string, deviceId: string): Promise { - if (!window.crypto || !window.crypto.subtle) { - return null; - } - let data; + let data: { encrypted?: BufferSource; iv?: BufferSource; cryptoKey?: CryptoKey } | undefined; try { data = await idbLoad("pickleKey", [userId, deviceId]); } catch (e) { logger.error("idbLoad for pickleKey failed", e); } - if (!data) { - return null; - } - if (!data.encrypted || !data.iv || !data.cryptoKey) { - logger.error("Badly formatted pickle key"); - return null; - } - - const additionalData = this.getPickleAdditionalData(userId, deviceId); - try { - const key = await crypto.subtle.decrypt( - { name: "AES-GCM", iv: data.iv, additionalData }, - data.cryptoKey, - data.encrypted, - ); - return encodeUnpaddedBase64(key); - } catch (e) { - logger.error("Error decrypting pickle key"); - return null; - } - } - - private getPickleAdditionalData(userId: string, deviceId: string): Uint8Array { - const additionalData = new Uint8Array(userId.length + deviceId.length + 1); - for (let i = 0; i < userId.length; i++) { - additionalData[i] = userId.charCodeAt(i); - } - additionalData[userId.length] = 124; // "|" - for (let i = 0; i < deviceId.length; i++) { - additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i); - } - return additionalData; + return (await buildAndEncodePickleKey(data, userId, deviceId)) ?? null; } /** @@ -424,7 +391,7 @@ export default abstract class BasePlatform { const iv = new Uint8Array(32); crypto.getRandomValues(iv); - const additionalData = this.getPickleAdditionalData(userId, deviceId); + const additionalData = getPickleAdditionalData(userId, deviceId); const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData }, cryptoKey, randomArray); try { diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 61097c13c21..ce7d7b5e2a2 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -37,6 +37,7 @@ import ActiveWidgetStore from "./stores/ActiveWidgetStore"; import PlatformPeg from "./PlatformPeg"; import { sendLoginRequest } from "./Login"; import * as StorageManager from "./utils/StorageManager"; +import * as StorageAccess from "./utils/StorageAccess"; import SettingsStore from "./settings/SettingsStore"; import { SettingLevel } from "./settings/SettingLevel"; import ToastStore from "./stores/ToastStore"; @@ -493,7 +494,7 @@ export interface IStoredSession { async function getStoredToken(storageKey: string): Promise { let token: string | undefined; try { - token = await StorageManager.idbLoad("account", storageKey); + token = await StorageAccess.idbLoad("account", storageKey); } catch (e) { logger.error(`StorageManager.idbLoad failed for account:${storageKey}`, e); } @@ -502,7 +503,7 @@ async function getStoredToken(storageKey: string): Promise { if (token) { try { // try to migrate access token to IndexedDB if we can - await StorageManager.idbSave("account", storageKey, token); + await StorageAccess.idbSave("account", storageKey, token); localStorage.removeItem(storageKey); } catch (e) { logger.error(`migration of token ${storageKey} to IndexedDB failed`, e); @@ -1064,7 +1065,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { + if (!getIDBFactory()) { + throw new Error("IndexedDB not available"); + } + idb = await new Promise((resolve, reject) => { + const request = getIDBFactory()!.open("matrix-react-sdk", 1); + request.onerror = reject; + request.onsuccess = (): void => { + resolve(request.result); + }; + request.onupgradeneeded = (): void => { + const db = request.result; + db.createObjectStore("pickleKey"); + db.createObjectStore("account"); + }; + }); +} + +/** + * Loads an item from an IndexedDB table within the underlying `matrix-react-sdk` database. + * + * If IndexedDB access is not supported in the environment, an error is thrown. + * + * @param {string} table The name of the object store in IndexedDB. + * @param {string | string[]} key The key where the data is stored. + * @returns {Promise} A promise that resolves with the retrieved item from the table. + */ +export async function idbLoad(table: string, key: string | string[]): Promise { + if (!idb) { + await idbInit(); + } + return new Promise((resolve, reject) => { + const txn = idb!.transaction([table], "readonly"); + txn.onerror = reject; + + const objectStore = txn.objectStore(table); + const request = objectStore.get(key); + request.onerror = reject; + request.onsuccess = (event): void => { + resolve(request.result); + }; + }); +} + +/** + * Saves data to an IndexedDB table within the underlying `matrix-react-sdk` database. + * + * If IndexedDB access is not supported in the environment, an error is thrown. + * + * @param {string} table The name of the object store in the IndexedDB. + * @param {string|string[]} key The key to use for storing the data. + * @param {*} data The data to be saved. + * @returns {Promise} A promise that resolves when the data is saved successfully. + */ +export async function idbSave(table: string, key: string | string[], data: any): Promise { + if (!idb) { + await idbInit(); + } + return new Promise((resolve, reject) => { + const txn = idb!.transaction([table], "readwrite"); + txn.onerror = reject; + + const objectStore = txn.objectStore(table); + const request = objectStore.put(data, key); + request.onerror = reject; + request.onsuccess = (event): void => { + resolve(); + }; + }); +} + +/** + * Deletes a record from an IndexedDB table within the underlying `matrix-react-sdk` database. + * + * If IndexedDB access is not supported in the environment, an error is thrown. + * + * @param {string} table The name of the object store where the record is stored. + * @param {string|string[]} key The key of the record to be deleted. + * @returns {Promise} A Promise that resolves when the record(s) have been successfully deleted. + */ +export async function idbDelete(table: string, key: string | string[]): Promise { + if (!idb) { + await idbInit(); + } + return new Promise((resolve, reject) => { + const txn = idb!.transaction([table], "readwrite"); + txn.onerror = reject; + + const objectStore = txn.objectStore(table); + const request = objectStore.delete(key); + request.onerror = reject; + request.onsuccess = (): void => { + resolve(); + }; + }); +} diff --git a/src/utils/StorageManager.ts b/src/utils/StorageManager.ts index faf5f7d27af..0cee3d9ef5d 100644 --- a/src/utils/StorageManager.ts +++ b/src/utils/StorageManager.ts @@ -19,18 +19,10 @@ import { logger } from "matrix-js-sdk/src/logger"; import SettingsStore from "../settings/SettingsStore"; import { Features } from "../settings/Settings"; +import { getIDBFactory } from "./StorageAccess"; const localStorage = window.localStorage; -// make this lazy in order to make testing easier -function getIndexedDb(): IDBFactory | undefined { - // just *accessing* _indexedDB throws an exception in firefox with - // indexeddb disabled. - try { - return window.indexedDB; - } catch (e) {} -} - // The JS SDK will add a prefix of "matrix-js-sdk:" to the sync store name. const SYNC_STORE_NAME = "riot-web-sync"; const LEGACY_CRYPTO_STORE_NAME = "matrix-js-sdk:crypto"; @@ -68,7 +60,7 @@ export async function checkConsistency(): Promise<{ }> { log("Checking storage consistency"); log(`Local storage supported? ${!!localStorage}`); - log(`IndexedDB supported? ${!!getIndexedDb()}`); + log(`IndexedDB supported? ${!!getIDBFactory()}`); let dataInLocalStorage = false; let dataInCryptoStore = false; @@ -86,7 +78,7 @@ export async function checkConsistency(): Promise<{ error("Local storage cannot be used on this browser"); } - if (getIndexedDb() && localStorage) { + if (getIDBFactory() && localStorage) { const results = await checkSyncStore(); if (!results.healthy) { healthy = false; @@ -96,7 +88,7 @@ export async function checkConsistency(): Promise<{ error("Sync store cannot be used on this browser"); } - if (getIndexedDb()) { + if (getIDBFactory()) { const results = await checkCryptoStore(); dataInCryptoStore = results.exists; if (!results.healthy) { @@ -138,7 +130,7 @@ interface StoreCheck { async function checkSyncStore(): Promise { let exists = false; try { - exists = await IndexedDBStore.exists(getIndexedDb()!, SYNC_STORE_NAME); + exists = await IndexedDBStore.exists(getIDBFactory()!, SYNC_STORE_NAME); log(`Sync store using IndexedDB contains data? ${exists}`); return { exists, healthy: true }; } catch (e) { @@ -152,7 +144,7 @@ async function checkCryptoStore(): Promise { if (await SettingsStore.getValue(Features.RustCrypto)) { // check first if there is a rust crypto store try { - const rustDbExists = await IndexedDBCryptoStore.exists(getIndexedDb()!, RUST_CRYPTO_STORE_NAME); + const rustDbExists = await IndexedDBCryptoStore.exists(getIDBFactory()!, RUST_CRYPTO_STORE_NAME); log(`Rust Crypto store using IndexedDB contains data? ${rustDbExists}`); if (rustDbExists) { @@ -162,7 +154,7 @@ async function checkCryptoStore(): Promise { // No rust store, so let's check if there is a legacy store not yet migrated. try { const legacyIdbExists = await IndexedDBCryptoStore.existsAndIsNotMigrated( - getIndexedDb()!, + getIDBFactory()!, LEGACY_CRYPTO_STORE_NAME, ); log(`Legacy Crypto store using IndexedDB contains non migrated data? ${legacyIdbExists}`); @@ -183,7 +175,7 @@ async function checkCryptoStore(): Promise { let exists = false; // legacy checks try { - exists = await IndexedDBCryptoStore.exists(getIndexedDb()!, LEGACY_CRYPTO_STORE_NAME); + exists = await IndexedDBCryptoStore.exists(getIDBFactory()!, LEGACY_CRYPTO_STORE_NAME); log(`Crypto store using IndexedDB contains data? ${exists}`); return { exists, healthy: true }; } catch (e) { @@ -214,77 +206,3 @@ async function checkCryptoStore(): Promise { export function setCryptoInitialised(cryptoInited: boolean): void { localStorage.setItem("mx_crypto_initialised", String(cryptoInited)); } - -/* Simple wrapper functions around IndexedDB. - */ - -let idb: IDBDatabase | null = null; - -async function idbInit(): Promise { - if (!getIndexedDb()) { - throw new Error("IndexedDB not available"); - } - idb = await new Promise((resolve, reject) => { - const request = getIndexedDb()!.open("matrix-react-sdk", 1); - request.onerror = reject; - request.onsuccess = (): void => { - resolve(request.result); - }; - request.onupgradeneeded = (): void => { - const db = request.result; - db.createObjectStore("pickleKey"); - db.createObjectStore("account"); - }; - }); -} - -export async function idbLoad(table: string, key: string | string[]): Promise { - if (!idb) { - await idbInit(); - } - return new Promise((resolve, reject) => { - const txn = idb!.transaction([table], "readonly"); - txn.onerror = reject; - - const objectStore = txn.objectStore(table); - const request = objectStore.get(key); - request.onerror = reject; - request.onsuccess = (event): void => { - resolve(request.result); - }; - }); -} - -export async function idbSave(table: string, key: string | string[], data: any): Promise { - if (!idb) { - await idbInit(); - } - return new Promise((resolve, reject) => { - const txn = idb!.transaction([table], "readwrite"); - txn.onerror = reject; - - const objectStore = txn.objectStore(table); - const request = objectStore.put(data, key); - request.onerror = reject; - request.onsuccess = (event): void => { - resolve(); - }; - }); -} - -export async function idbDelete(table: string, key: string | string[]): Promise { - if (!idb) { - await idbInit(); - } - return new Promise((resolve, reject) => { - const txn = idb!.transaction([table], "readwrite"); - txn.onerror = reject; - - const objectStore = txn.objectStore(table); - const request = objectStore.delete(key); - request.onerror = reject; - request.onsuccess = (): void => { - resolve(); - }; - }); -} diff --git a/src/utils/tokens/pickling.ts b/src/utils/tokens/pickling.ts new file mode 100644 index 00000000000..c113559a69a --- /dev/null +++ b/src/utils/tokens/pickling.ts @@ -0,0 +1,88 @@ +/* +Copyright 2016 Aviral Dasgupta +Copyright 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2020, 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +/** + * Calculates the `additionalData` for the AES-GCM key used by the pickling processes. This + * additional data is *not* encrypted, but *is* authenticated. The additional data is constructed + * from the user ID and device ID provided. + * + * The later-constructed pickle key is used to decrypt values, such as access tokens, from IndexedDB. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams for more information on + * `additionalData`. + * + * @param {string} userId The user ID who owns the pickle key. + * @param {string} deviceId The device ID which owns the pickle key. + * @return {Uint8Array} The additional data as a Uint8Array. + */ +export function getPickleAdditionalData(userId: string, deviceId: string): Uint8Array { + const additionalData = new Uint8Array(userId.length + deviceId.length + 1); + for (let i = 0; i < userId.length; i++) { + additionalData[i] = userId.charCodeAt(i); + } + additionalData[userId.length] = 124; // "|" + for (let i = 0; i < deviceId.length; i++) { + additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i); + } + return additionalData; +} + +/** + * Decrypts the provided data into a pickle key and base64-encodes it ready for use elsewhere. + * + * If `data` is undefined in part or in full, returns undefined. + * + * If crypto functions are not available, returns undefined regardless of input. + * + * @param data An object containing the encrypted pickle key data: encrypted payload, initialization vector (IV), and crypto key. Typically loaded from indexedDB. + * @param userId The user ID the pickle key belongs to. + * @param deviceId The device ID the pickle key belongs to. + * @returns A promise that resolves to the encoded pickle key, or undefined if the key cannot be built and encoded. + */ +export async function buildAndEncodePickleKey( + data: { encrypted?: BufferSource; iv?: BufferSource; cryptoKey?: CryptoKey } | undefined, + userId: string, + deviceId: string, +): Promise { + if (!crypto?.subtle) { + return undefined; + } + if (!data || !data.encrypted || !data.iv || !data.cryptoKey) { + return undefined; + } + + try { + const additionalData = getPickleAdditionalData(userId, deviceId); + const pickleKeyBuf = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: data.iv, additionalData }, + data.cryptoKey, + data.encrypted, + ); + if (pickleKeyBuf) { + return encodeUnpaddedBase64(pickleKeyBuf); + } + } catch (e) { + logger.error("Error decrypting pickle key"); + } + + return undefined; +} diff --git a/src/utils/tokens/tokens.ts b/src/utils/tokens/tokens.ts index 864b6b2090a..f526775e63e 100644 --- a/src/utils/tokens/tokens.ts +++ b/src/utils/tokens/tokens.ts @@ -17,7 +17,7 @@ limitations under the License. import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; import { logger } from "matrix-js-sdk/src/logger"; -import * as StorageManager from "../StorageManager"; +import * as StorageAccess from "../StorageAccess"; /** * Utility functions related to the storage and retrieval of access tokens @@ -50,10 +50,10 @@ async function pickleKeyToAesKey(pickleKey: string): Promise { for (let i = 0; i < pickleKey.length; i++) { pickleKeyBuffer[i] = pickleKey.charCodeAt(i); } - const hkdfKey = await window.crypto.subtle.importKey("raw", pickleKeyBuffer, "HKDF", false, ["deriveBits"]); + const hkdfKey = await crypto.subtle.importKey("raw", pickleKeyBuffer, "HKDF", false, ["deriveBits"]); pickleKeyBuffer.fill(0); return new Uint8Array( - await window.crypto.subtle.deriveBits( + await crypto.subtle.deriveBits( { name: "HKDF", hash: "SHA-256", @@ -142,7 +142,7 @@ export async function persistTokenInStorage( // Save either the encrypted access token, or the plain access // token if there is no token or we were unable to encrypt (e.g. if the browser doesn't // have WebCrypto). - await StorageManager.idbSave("account", storageKey, encryptedToken || token); + await StorageAccess.idbSave("account", storageKey, encryptedToken || token); } catch (e) { // if we couldn't save to indexedDB, fall back to localStorage. We // store the access token unencrypted since localStorage only saves @@ -155,7 +155,7 @@ export async function persistTokenInStorage( } } else { try { - await StorageManager.idbSave("account", storageKey, token); + await StorageAccess.idbSave("account", storageKey, token); } catch (e) { if (!!token) { localStorage.setItem(storageKey, token); diff --git a/test/Lifecycle-test.ts b/test/Lifecycle-test.ts index fac59b235ae..271cae8b790 100644 --- a/test/Lifecycle-test.ts +++ b/test/Lifecycle-test.ts @@ -26,7 +26,7 @@ import StorageEvictedDialog from "../src/components/views/dialogs/StorageEvicted import { logout, restoreFromLocalStorage, setLoggedIn } from "../src/Lifecycle"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; import Modal from "../src/Modal"; -import * as StorageManager from "../src/utils/StorageManager"; +import * as StorageAccess from "../src/utils/StorageAccess"; import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser, mockPlatformPeg } from "./test-utils"; import { OidcClientStore } from "../src/stores/oidc/OidcClientStore"; import { makeDelegatedAuthConfig } from "./test-utils/oidc"; @@ -128,13 +128,13 @@ describe("Lifecycle", () => { }; const initIdbMock = (mockStore: Record> = {}): void => { - jest.spyOn(StorageManager, "idbLoad") + jest.spyOn(StorageAccess, "idbLoad") .mockClear() .mockImplementation( // @ts-ignore mock type async (table: string, key: string) => mockStore[table]?.[key] ?? null, ); - jest.spyOn(StorageManager, "idbSave") + jest.spyOn(StorageAccess, "idbSave") .mockClear() .mockImplementation( // @ts-ignore mock type @@ -144,7 +144,7 @@ describe("Lifecycle", () => { mockStore[tableKey] = table; }, ); - jest.spyOn(StorageManager, "idbDelete").mockClear().mockResolvedValue(undefined); + jest.spyOn(StorageAccess, "idbDelete").mockClear().mockResolvedValue(undefined); }; const homeserverUrl = "https://server.org"; @@ -258,16 +258,16 @@ describe("Lifecycle", () => { expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false"); expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId); - expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); + expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); // dont put accessToken in localstorage when we have idb expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken); }); it("should persist access token when idb is not available", async () => { - jest.spyOn(StorageManager, "idbSave").mockRejectedValue("oups"); + jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups"); expect(await restoreFromLocalStorage()).toEqual(true); - expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); + expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); // put accessToken in localstorage as fallback expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken); }); @@ -316,11 +316,7 @@ describe("Lifecycle", () => { // refresh token from storage is re-persisted expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_refresh_token", "true"); - expect(StorageManager.idbSave).toHaveBeenCalledWith( - "account", - "mx_refresh_token", - refreshToken, - ); + expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken); }); it("should create new matrix client with credentials", async () => { @@ -359,7 +355,7 @@ describe("Lifecycle", () => { expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true"); // token encrypted and persisted - expect(StorageManager.idbSave).toHaveBeenCalledWith( + expect(StorageAccess.idbSave).toHaveBeenCalledWith( "account", "mx_access_token", encryptedTokenShapedObject, @@ -368,7 +364,7 @@ describe("Lifecycle", () => { it("should persist access token when idb is not available", async () => { // dont fail for pickle key persist - jest.spyOn(StorageManager, "idbSave").mockImplementation( + jest.spyOn(StorageAccess, "idbSave").mockImplementation( async (table: string, key: string | string[]) => { if (table === "account" && key === "mx_access_token") { throw new Error("oups"); @@ -378,7 +374,7 @@ describe("Lifecycle", () => { expect(await restoreFromLocalStorage()).toEqual(true); - expect(StorageManager.idbSave).toHaveBeenCalledWith( + expect(StorageAccess.idbSave).toHaveBeenCalledWith( "account", "mx_access_token", encryptedTokenShapedObject, @@ -422,7 +418,7 @@ describe("Lifecycle", () => { // refresh token from storage is re-persisted expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_refresh_token", "true"); - expect(StorageManager.idbSave).toHaveBeenCalledWith( + expect(StorageAccess.idbSave).toHaveBeenCalledWith( "account", "mx_refresh_token", encryptedTokenShapedObject, @@ -502,7 +498,7 @@ describe("Lifecycle", () => { expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false"); expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId); - expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); + expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); // dont put accessToken in localstorage when we have idb expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken); }); @@ -513,14 +509,14 @@ describe("Lifecycle", () => { refreshToken, }); - expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); - expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken); + expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); + expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_refresh_token", refreshToken); // dont put accessToken in localstorage when we have idb expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken); }); it("should remove any access token from storage when there is none in credentials and idb save fails", async () => { - jest.spyOn(StorageManager, "idbSave").mockRejectedValue("oups"); + jest.spyOn(StorageAccess, "idbSave").mockRejectedValue("oups"); await setLoggedIn({ ...credentials, // @ts-ignore @@ -534,7 +530,7 @@ describe("Lifecycle", () => { it("should clear stores", async () => { await setLoggedIn(credentials); - expect(StorageManager.idbDelete).toHaveBeenCalledWith("account", "mx_access_token"); + expect(StorageAccess.idbDelete).toHaveBeenCalledWith("account", "mx_access_token"); expect(sessionStorage.clear).toHaveBeenCalled(); expect(mockClient.clearStores).toHaveBeenCalled(); }); @@ -566,7 +562,7 @@ describe("Lifecycle", () => { }); // unpickled access token saved - expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); + expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); expect(mockPlatform.createPickleKey).not.toHaveBeenCalled(); }); @@ -585,16 +581,12 @@ describe("Lifecycle", () => { expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId); expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_pickle_key", "true"); - expect(StorageManager.idbSave).toHaveBeenCalledWith( + expect(StorageAccess.idbSave).toHaveBeenCalledWith( "account", "mx_access_token", encryptedTokenShapedObject, ); - expect(StorageManager.idbSave).toHaveBeenCalledWith( - "pickleKey", - [userId, deviceId], - expect.any(Object), - ); + expect(StorageAccess.idbSave).toHaveBeenCalledWith("pickleKey", [userId, deviceId], expect.any(Object)); // dont put accessToken in localstorage when we have idb expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken); }); @@ -604,12 +596,12 @@ describe("Lifecycle", () => { await setLoggedIn(credentials); // persist the unencrypted token - expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); + expect(StorageAccess.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken); }); it("should persist token in localStorage when idb fails to save token", async () => { // dont fail for pickle key persist - jest.spyOn(StorageManager, "idbSave").mockImplementation( + jest.spyOn(StorageAccess, "idbSave").mockImplementation( async (table: string, key: string | string[]) => { if (table === "account" && key === "mx_access_token") { throw new Error("oups"); @@ -624,7 +616,7 @@ describe("Lifecycle", () => { it("should remove any access token from storage when there is none in credentials and idb save fails", async () => { // dont fail for pickle key persist - jest.spyOn(StorageManager, "idbSave").mockImplementation( + jest.spyOn(StorageAccess, "idbSave").mockImplementation( async (table: string, key: string | string[]) => { if (table === "account" && key === "mx_access_token") { throw new Error("oups"); diff --git a/test/components/structures/MatrixChat-test.tsx b/test/components/structures/MatrixChat-test.tsx index 38309b81787..d112cebe81d 100644 --- a/test/components/structures/MatrixChat-test.tsx +++ b/test/components/structures/MatrixChat-test.tsx @@ -29,7 +29,7 @@ import { defer, sleep } from "matrix-js-sdk/src/utils"; import { UserVerificationStatus } from "matrix-js-sdk/src/crypto-api"; import MatrixChat from "../../../src/components/structures/MatrixChat"; -import * as StorageManager from "../../../src/utils/StorageManager"; +import * as StorageAccess from "../../../src/utils/StorageAccess"; import defaultDispatcher from "../../../src/dispatcher/dispatcher"; import { Action } from "../../../src/dispatcher/actions"; import { UserTab } from "../../../src/components/views/dialogs/UserTab"; @@ -220,8 +220,8 @@ describe("", () => { headers: { "content-type": "application/json" }, }); - jest.spyOn(StorageManager, "idbLoad").mockReset(); - jest.spyOn(StorageManager, "idbSave").mockResolvedValue(undefined); + jest.spyOn(StorageAccess, "idbLoad").mockReset(); + jest.spyOn(StorageAccess, "idbSave").mockResolvedValue(undefined); jest.spyOn(defaultDispatcher, "dispatch").mockClear(); jest.spyOn(defaultDispatcher, "fire").mockClear(); @@ -459,7 +459,7 @@ describe("", () => { describe("when login succeeds", () => { beforeEach(() => { - jest.spyOn(StorageManager, "idbLoad").mockImplementation( + jest.spyOn(StorageAccess, "idbLoad").mockImplementation( async (_table: string, key: string | string[]) => (key === "mx_access_token" ? accessToken : null), ); loginClient.getProfileInfo.mockResolvedValue({ @@ -553,7 +553,7 @@ describe("", () => { beforeEach(async () => { await populateStorageForSession(); - jest.spyOn(StorageManager, "idbLoad").mockImplementation(async (table, key) => { + jest.spyOn(StorageAccess, "idbLoad").mockImplementation(async (table, key) => { const safeKey = Array.isArray(key) ? key[0] : key; return mockidb[table]?.[safeKey]; }); @@ -868,7 +868,7 @@ describe("", () => { mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }] }); - jest.spyOn(StorageManager, "idbLoad").mockImplementation(async (table, key) => { + jest.spyOn(StorageAccess, "idbLoad").mockImplementation(async (table, key) => { const safeKey = Array.isArray(key) ? key[0] : key; return mockidb[table]?.[safeKey]; }); @@ -1164,7 +1164,7 @@ describe("", () => { describe("when login succeeds", () => { beforeEach(() => { - jest.spyOn(StorageManager, "idbLoad").mockImplementation( + jest.spyOn(StorageAccess, "idbLoad").mockImplementation( async (_table: string, key: string | string[]) => { if (key === "mx_access_token") { return accessToken as any; diff --git a/test/utils/StorageAccess-test.ts b/test/utils/StorageAccess-test.ts new file mode 100644 index 00000000000..41042c486d7 --- /dev/null +++ b/test/utils/StorageAccess-test.ts @@ -0,0 +1,55 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import "core-js/stable/structured-clone"; // for idb access +import "fake-indexeddb/auto"; + +import { idbDelete, idbLoad, idbSave } from "../../src/utils/StorageAccess"; + +const NONEXISTENT_TABLE = "this_is_not_a_table_we_use_ever_and_so_we_can_use_it_in_tests"; +const KNOWN_TABLES = ["account", "pickleKey"]; + +describe("StorageAccess", () => { + it.each(KNOWN_TABLES)("should save, load, and delete from known table '%s'", async (tableName: string) => { + const key = ["a", "b"]; + const data = { hello: "world" }; + + // Should start undefined + let loaded = await idbLoad(tableName, key); + expect(loaded).toBeUndefined(); + + // ... then define a value + await idbSave(tableName, key, data); + + // ... then check that value + loaded = await idbLoad(tableName, key); + expect(loaded).toEqual(data); + + // ... then set it back to undefined + await idbDelete(tableName, key); + + // ... which we then check again + loaded = await idbLoad(tableName, key); + expect(loaded).toBeUndefined(); + }); + + it("should fail to save, load, and delete from a non-existent table", async () => { + // Regardless of validity on the key/data, or write order, these should all fail. + await expect(() => idbSave(NONEXISTENT_TABLE, "whatever", "value")).rejects.toThrow(); + await expect(() => idbLoad(NONEXISTENT_TABLE, "whatever")).rejects.toThrow(); + await expect(() => idbDelete(NONEXISTENT_TABLE, "whatever")).rejects.toThrow(); + }); +});