From bd53d17bcbcb286d84f5a9bcf84723d707a2b8d1 Mon Sep 17 00:00:00 2001 From: arjunyel Date: Wed, 29 Nov 2023 21:22:26 -0600 Subject: [PATCH] Switch from CryptoJS to WebCrypto API --- README.md | 50 +++++++++--- bun.lockb | Bin 280690 -> 282839 bytes package.json | 7 +- src/helpers/base64.ts | 48 +++++++++++ src/server/csrf.ts | 57 +++++++++---- src/server/honeypot.ts | 143 +++++++++++++++++++++++++++------ test/server/csrf.test.ts | 56 ++++++++++--- test/server/honeypot.test.ts | 151 +++++++++++++++++++++++++++-------- 8 files changed, 409 insertions(+), 103 deletions(-) create mode 100644 src/helpers/base64.ts diff --git a/README.md b/README.md index 51f99e6..3ca2337 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ Additional optional dependencies may be needed, all optional dependencies are: - `@remix-run/react` (also `@remix-run/router` but you should be using the React one) - `@remix-run/node` or `@remix-run/cloudflare` or `@remix-run/deno` (actually it's `@remix-run/server-runtime` but you should use one of the others) -- `crypto-js` - `is-ip` - `intl-parse-accept-language` - `react` @@ -23,7 +22,7 @@ The utils that require an extra optional dependency mention it in their document If you want to install them all run: ```sh -npm add crypto-js is-ip intl-parse-accept-language zod +npm add is-ip intl-parse-accept-language zod ``` React and the `@remix-run/*` packages should be already installed in your project. @@ -408,12 +407,26 @@ Additionally, the `cors` function accepts a `options` object as a third optional ### CSRF > **Note** -> This depends on `react`, `crypto-js`, and a Remix server runtime. +> This depends on `react`, the [WebCrypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API), and a Remix server runtime. The CSRF related functions let you implement CSRF protection on your application. This part of Remix Utils needs React and server-side code. +The [WebCrypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) is required. If your enviroment doesn't support it you must supply a polyfill, such as [`@peculiar/webcrypto`](https://github.com/PeculiarVentures/webcrypto), to either `globalThis.crypto` or the CSRF constructor. + +```ts +import { Crypto } from "@peculiar/webcrypto"; + +globalThis.crypto = new Crypto(); + +// Or +export const csrf = new CSRF({ + ...otherCsrfOptions + webCrypto: new Crypto(), +}); +``` + First create a new CSRF instance. ```ts @@ -433,8 +446,8 @@ export const csrf = new CSRF({ cookie, // what key in FormData objects will be used for the token, defaults to `csrf` formDataKey: "csrf", - // an optional secret used to sign the token, recommended for extra safety - secret: "s3cr3t", + // HMAC SHA-256 secret key, a string with 256 bits (32 bytes) of entropy + secret: "Required in production", }); ``` @@ -444,14 +457,14 @@ Then you can use `csrf` to generate a new token. import { csrf } from "~/utils/csrf.server"; export async function loader({ request }: LoaderArgs) { - let token = csrf.generate(); + let token = await csrf.generate(); } ``` You can customize the token size by passing the byte size, the default one is 32 bytes which will give you a string with a length of 43 after encoding. ```ts -let token = csrf.generate(64); // customize token length +let token = await csrf.generate(64); // customize token length ``` You will need to save this token in a cookie and also return it from the loader. For convenience, you can use the `CSRF#commitToken` helper. @@ -1904,12 +1917,26 @@ This means that the `respondTo` helper will prioritize any handler that match `t ### Form Honeypot > **Note** -> This depends on `react` and `crypto-js`. +> This depends on `react` and the [`WebCrypto API`](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API). Honeypot is a simple technique to prevent spam bots from submitting forms. It works by adding a hidden field to the form that bots will fill, but humans won't. There's a pair of utils in Remix Utils to help you implement this. +The [WebCrypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) is required. If your enviroment doesn't support it you must supply a polyfill, such as [`@peculiar/webcrypto`](https://github.com/PeculiarVentures/webcrypto), to either `globalThis.crypto` or the honeypot constructor. + +```ts +import { Crypto } from "@peculiar/webcrypto"; + +globalThis.crypto = new Crypto(); + +// Or +export const csrf = new Honeypot({ + ...otherHoneypotOptions + webCrypto: new Crypto(), +}); +``` + First, create a `honeypot.server.ts` where you will instantiate and configure your Honeypot. ```tsx @@ -1921,7 +1948,8 @@ export const honeypot = new Honeypot({ randomizeNameFieldName: false, nameFieldName: "name__confirm", validFromFieldName: "from__confirm", // null to disable it - encryptionSeed: undefined, // Ideally it should be unique even between processes + // HKDF SHA-256 key, a string with 256 bits (32 bytes) of entropy + encryptionSeed: "required in production", }); ``` @@ -1933,7 +1961,7 @@ import { honeypot } from "~/honeypot.server"; export async function loader() { // more code here - return json({ honeypotInputProps: honeypot.getInputProps() }); + return json({ honeypotInputProps: await honeypot.getInputProps() }); } ``` @@ -1981,7 +2009,7 @@ import { honeypot } from "~/honeypot.server"; export async function action({ request }) { let formData = await request.formData(); try { - honeypot.check(formData); + await honeypot.check(formData); } catch (error) { if (error instanceof SpamError) { // handle spam requests here diff --git a/bun.lockb b/bun.lockb index 5db8d2ecf5524530825d178344ebe4cf27e943ba..dc5bf3dcc40366077ec08b3fb2526dc9356aeef8 100755 GIT binary patch delta 45477 zcmeEvd3=pW`}es|a>$L4SV9D`?}@Au5;@cw1Sw))A_+nyve-(Spjv4u>0PFZqO^9Z z)vB%bRuH7x+Nq)_YVBfwzu$Z25Pj1Ap5Obt&-+);$Cd9~*IYAm%{4RE%suB$+@9%v zbdmc^-+Em#Z)<_Ww!ZLiN2`AGhkv=cTh-{Sn2av3?H*q{#?>clMB6UT7JhPbdU$8n z+F>;r`J=eSk~Iwi9r#j8yeS-=n30g4VX=%<@)Ias3i#n zBujik!bqQl)T9`d(H6)a{(=hW33`A9dmD>1@63*MvX+vRV|ptT-WrsHS0$s(%C!n zc6})x=O0S1TYge}egkP%`GyiVf#=|WG3cvxACQ%d3OKI@0@?2vAQjpvz6p>GBqpbhN=!>gyJ~paytSf6 z6I-3gu>sPUcp&S*noAmv#?J@Jx`xK4`wUKs9TJ0l*4-XRADr-$ZTJLBd8V=4=IuGC zv21A%klGs*_A$=cyffWGWrrT3EFF-P5+9pB)M6Q&IAl~>Lb@feiL~K5@@a3zh>`TC zr9@L%&Jnm^V_JMdhUEhEIPVVvX_F%m)fs+=$qW zxSX0^TB}@Gbo?Sizq$|p|midVzebN$!B#cdh8!JNI2UD76C8Q@M zCTCbI{o2XybX8a)Qr44{Fgls8w}LMQ{j)MZGwTWxT*77uPE5D70?)Au1Ja-p9V9;z z$by4_>{cV>)39Q|TEJoHKI39XBw4UVN5-b5Cp>H`^=<*_@5CX=DQO7em%+1M4pMAV zhjfc$kR1avI59ci2SKM;XIbz`Cy9u3DRHqG*eITlc*js7JvGHx|{dv$g~6)`#JPD zwr#sfqgQ}urvti6-Z8=BQ{pnx5|a}aARnDIdg$(YSd46=q>-xE$vo3TE`{Adj@t}q z((1vn=^3f8FfCzJdg9=5mdIYR#frUUOM`*5v>1^3sVK)t8v|tEbPUtNK5~|Igq*_^ zuIk_0*Q_ryYYP%IeRMyGi9l`%&i!RqYM=n;L0lWzz;{6EhYyeyrYDRTosed+>;lj6 z8yB0Dl!#NwKIn0Lhhb{Rp(EQAzh0pn-c0jw5$`i{_z=f<--?wLFBl{j<|JTcRFsgG zmXhX^F!mkP#)?x_{$B;Mzlx^SaU!6{TELL%4UssB20efq2TNDA7$TR>1|Sue0qN>s zqeb!ZnZAi~r>Y60Lp^|;_lTdIQI_{m3$?#Qtu)&)ZU1Vzj`YdE1hZIH!W{a~;jj0= zGcM&N$@->)$G8M!O+D4DGvU^ulZMndzkZ%EUTC4z;2G&BWoED{lTnWXop2)k6k=FGE zmIS{Wa+J%=%143?JVpi^yai;13m_-|1jzo5fHT;QUO?_strZSQ8Hs_jSgt_N1M+1` zzr%Q0?<f;q2)D+g{Qki8fHq*tn=IC)2CO+)qc zW*;Esk2G2C5|GWLk4uglj)QO=Lk?HU$b@7IKJfOKbgV7QJ93yeBA;d|zF5|B2NYdNlFu>8^pBu<~TlRm+Tv>6x>i?v9vVnXc z{p2{5rhsREV~vS!$tcDzBCPI*EL`<13|i9dgd@P9Mv1K}Kxp@|pkT2ipJXc{VfafBQh& zcb%M!?Sb^)r7xs^*8u5;g&U=O3XtQMuksfGxk@$x>4xekUzb7s7W`2h0>^Y1l90>= zZ^_sq=a)Q9rd!_KYO&OY-gqD<#6Tb$NKkrq@SGS$!E?MEyKoQiTo<80Z{W4nAg_-9G z{PVu7rC?L7iHz{CWkrsS`T-V2Rmu_NDgXoa${ycnt z%HPO*9_3ND#RfgfmG3ll_OK zbY^7x;t`DZ>(z z5(o7K&-{)+8usve$(J}G4RH*G(cGiL3kM*i@}HlLi=Yu&`x6RtTsNJP!@#RBT=b<6 z<}(kxu;kRI?|F1`|MX^^o7PV+*LTxG-`@FX^x0NfJ@$>8|J$jrJ(lE#{d>hq z!@X~)3z*hVEAudN1MOOpp$FM*D=<+T8+ra=+7Cu{kX@@_u=ySngp|C1=@gle& za88DMV2D=J$PTgF%3un&GNy-w*?J(=#!Sses-2m-_%yd3)|Ql|A|>@UASKH_c$(W1 zGq$5yb_Pe>`WU1*PfU+$pBZ{{yIumv^rnzmOrN&_*BG2RSM>?t zI8qiP2eWLw(zF^mjY70vjK~&t&CkegVb=#^Maw#JT7>8e!ExMN%-MJf94C-wxch|Y z)hc0LgLCF6>OH~vfwQq~ZK|QSwA(5pKK3?TTZY-jAl1`M9eOIOhmGk)Q#Kx{zGmtu zQt@Ugtg6M*-%QPUn))3nsn->Im&{#)>$D9`#;Vwvop-Q>5uYwD7a9R~3yS?2urmi_1 z(!#WLhTg%hKLpQVf|)@fdh>c{8XT5yP>5z2xzKoAAK?SCa_oq1Z{Tn{HByZ1j&}Vd zWb9TE!@Vtr3B6X%7!>LPCPr3|b@(Yb&LJ0bRo(*E9vtQiJQQZ=o$UHVpQq-av5nvY zOnp{-7aV7b8S!*q*vY!!Jc-s(dkCENku4zVYXS*KW$YSY&9Lz(^ylF;c z7rX5+WZ}m2_F-BHBe#oP?}A~Ffd>&$n{7l!+jTd8*%~?pS$iWFvMk6rVnvPU$Pn#g zBl1PN_M?&gqFt{9GwC>+k@I4RJ{cTW0>-&*h;1XdP$Q~cnC%u)0Zi#NFfP)1_uvpc z7M!$^+&XYjM9=blL-bqVTA6La4qI>#>*9JGffVI3!mR_xwT<=F24%o;2oUd3Us$lz zl-wKO*s_z6vpUoT3=OrJO+5z3xd11kSYu>hgt*+mrGlf2oQ!C6@CZ2Bcuw0;XDrHA zMpTP1eK1n28*8#lh`t1ztS-7;h<3_|>}l6MAz*`u(p^GqoxwFVrZ)=Hvyoy+i#hSW zHX?i3^+!qUQ^KS8nsI82r0p)Qyv3=!}M2675G zHg4Lb?K1ROyWSM5opT9My+w%aC2-vgSHCdZL8O`)uEAk?aV)itW`#7Q2RJruu04GQ zxSmhBOW^2NxhFJgDLaRn8-?g`;5g4^cNT)9C71yiZ6`R3okJcbUk7mb$7zZvG6PB0 zX)y!RQE;?YjRCd`mXmY6H#k~`THXkC0n-k7`_JU;zmc9OnVih}eQ@j~rY8oiDSV%1=F$C|z{zPxSKS2n zY%uCZ$y#J|PXZ@hoYOf(TVdo5v)j%dKU<#Zk%uCw)&Y7aYrAdcGOzq7>n0SlkS&n!_n~j~U?T1v!BG z!980IPr&&huc(pJF+^`4Ez4;}&f!pJFn(q?b|S?hW)#qW14k>MG&n@}eNkE{r$7=o zR_AQw^bN5sWuD>MHcY!{M2@uUUYH#+!f-*wf}{PI$KZtG&<5u5QE)UFQLjaaR>sIq zwcEOMGf#eqiSHvNXL3%T5dAhd4kQ+Ib42FuGSA$@aJtV;v+H{xqs4MQKLSUeVvz@h z*y3Z%{Ub0;pUo8Ha$W5OM_VQ5)kE%uvW9-(RDIZGXBoK}cKtSF^bvZF=~TO?)IhMn zPTC(F{ES7wyfxtZnl&@d7BlqGcCD`wIofWU(F-2oUWVN^ceGuv-COny@fquP6gXN0 zzjq9=Z3bsITswv7_mM)EAji~2jMm56^(}oIi@_bf{S6!jjpNrP#M-v6u`eOQIsxDt zp`U~)n0)jNP6ze+IaWU7)Fg7|Jlu&C!j5c1ckTbQc1*Um;5gaPbL^8dz+u?QVIe;R z_bigM86ZbhS~|nX9>VK-LucMHn<}MF!FX+>6^F#c+$b77?Pq1&(85Mmap;a=6wuK2A2F`A^LrA_=gea zKGZi()&g&1jx7KeA=~d_?v0stJs{pOU!tKO53ZHjD(CMqaI`_rmmk2fG&Z0vA$pYr zhcBbCeg=Z$Oh-F7{LM4;iFVsr$c7lM6XAuy*eQ?;n&w7nCczkX*lD<2zVa;71RT34x05m8*nQ@Ox+q0+E!ZxC3*g?T zmrs%-BTuf;;5fo^lb#HYBO^U_3LIxXILylGBj7)Cj>IFyPRcGU0mpa(pX15q1UQbS z8DF({BXX);uazS8VI<~y0yuWrj1;y-;QAX;Q^RnmkIc4fXN>G@yY8N9u>?b>xEWS^ zgOfHg{!Rl&r()!=s<(sVJVP{wbDYy0b@OD^9GsjDIiH3y3lEQ&;+v78&&-2@?H;&p zG{72_ZnU2oVKdVCEChjm1u1SE2-1D|terc}t`E!L%{;BgytY2hFxpR#&{`SU)9toO zW6(`=+=dzYTXuad_!pommw(Z*#=f8kz4cgWuela&nc(14|1f>WQ#lVy55RF85Cz`~ zbr~m*A=2Ha!7-@GaOCq6IP)O$GE&?EWW}q&bu*8n+IYue<1sY`9JV+#_zqIiI=D*v z-pI|d+aA4))0HuOXqevP6-Usc%jbg&Mcvrtd_s-fx9xhFOu0bN8XP?Y94Ct0sPe$Q z=-_NGX3_6_P?*M)v@j+_{|a2Yr(DU24l8kru=bp2>@(~xka3*MT5X5Hbu_(;bIdHe z9y>`sj!Ca^RiX`d;7=ZRr`b`x&^N4$kKKs=1m+hv@^5Qhit&>I^2-nBF-| zKaCVe0?l*`b)Ld7$>84)DS8j#wll&MIJt^BcqhRzcu2b&y@q}oQGs}nN2-mH*FW4D ziE#5C;~r8{l~FkAb!iU!kBNwvImCOu}`XAsrq7<5p z39+?$6XqCs^TKddkvre6p8>CCBtmwJX>wze<1zyrO-FE;7or_8a^JV>9i~4W9p4b! zYH(P8m>*@{a>x;q^;O{1{J=KjGJ`II7xh+5Nn0S#0vBX@cfBK}Uq%Y&82CSDI@~qW zF;6%)Ip8>)@_2FbX&(LMpCe~E0_L}&E?|@^@hGzc91bR|5?w6+ww&PDI}y*uf#VpO zAy~U*L@u`50t|EhWQMyS;fGA!3}gqu1(~hq#D%)NBbNi3!x*;&$Mb-6TaL=(K4vP? z6XvF7D>KWSMTldgk>aW}<@y?M0pM^P!_L!S_S549dNvMRW8}%rel0lp;C%@x26FS! z#8yW*pUo_KqJ<2;d;ZUiSI?j599hEF4*hQ8d6 zceUi&mUSKg$LTJ2vC{9!p2+Q@6*xKua}N(bU}zXNI6N+V1Fose0#{Y>93IXl5|%9^KY>*M#0VrwFBV2oU2*WX{@m^?W# zp)L#M*$XXWCtU%qB}#emwnx7Xt_e7I^SQ-uk?c7_3~qvO+rHMWZ-q>@g5_g#`T(5a z+B{5;LyBI4Cq^TbgQEvgGCtG=j0`heaZMK&aG5?69Gi#HMyLxISqhU2&jtD?cI{Uq z@)Nt>`9oQ|9P(^%p@u8M>3*a*9nBzUee|Kx{?iCOlo^?R*o?$MW^Gy$yvsS$fe*om@=L?e=GwXw`^#FV;<%J zqScI`bQa*N&e%{tss8&O-rY~??p6PW6cRHT_&ij>T~_EZ-9sj0Uu z+!+a(xdSO#pxAngCCZq-CEOVaDO!kBXS22&W+qz*|IE?WWTd3t0inky^)HN zmLMgq`vs{ErmV>Zhu$Qlq~4*YvI<{dG8<8A!t_2!(Gxh+4G+;Df#d3yT!)QvE+d3u z5`6;h>3~}wZ#3HPiqK;=p>neT&*EQzBDiG_v>D_20JQdqdgAWIQN;$%}hdw;idTPjxF8 z*gFI9@z%$z|(~>{i`tB zCZrOW();X`j*t;}7P!VJh85}`V%@*fXrCLQ_ub`q`put)iyLq)p@PLREyVh0mr-n= z8R7QXZL@bO5=sMj7c-Z&DW7d`AaQZo*;2FEyn z7>%Gb9UO)SccPb(>H@iZ3JlGcV=J$*-g=HZ4UW@S?k)b`NXOwe6W?gQWaNHl*H=Q; z8X9oXiy?abBhq|x1F-c6*O6gRUx5@Ykxq8{R(2W3671_y;JD;u`|p8c%)?2nMTo2S zcZ#!EELg*S4c_)~(c>Ug8{T|uc;JF$aU7oW!Er8|=UQ9Q?-?|v&kD13L8_gR7agX5 zh!n>Z9-fCwgQGHB;CzE^{Vi~;3p)jlY&*eqGHd28<91Arnz?i7Dd1>>+%0y4>km$j zQ}yFAEI<=iHNC-cw9SoDpAC-XNql528Oa`Lp40!~dUoMBgjmP99{`+|rA_`(W5NSGfY3k)>5=aKSQB`30ic!fiN%pVHkryv#upJGa%N9vDc z`%I)N1tNPq22>n03B*r9r2b?O4R{U2Phsc*`8+fCZ&3FCZxsHg3jV|V%)%^mMx_f? z#YFaWvEmCNJ+VZ|3nKNGf|$QdZ7_do@#*i%(R zWU`{-3!)Br4V7P0VJ)Rcq$lero=Co)hW06|uQCclW;IX+e3TxMNngbiX{f)#ASEX< z8Laq%NCQHQYUj%s4bQufnoucJX%bmu7?7fHB_}f3Oz|cfBhHs^0-+^BNr^7t+W;wQ ztKsl1Zimeh@n%krsDW{NEuXXm_RGL+KYpih3$Jk;z^f zVlflFRR)m__65=r1AxpL$RB?~iei<#FyvesiTql?SxS#cL+2`fF6ROpoDTtI@2iZz zL#iym50+b`^#2a2@`2L(7c~9qzmnOv%HnZo51;zML|1%A+z zs};WnNDr)MCO(-=F#VYl5ZTZM#S@wQLh*lxRN1KXh-`R^;)zUd#ShlMP4bzR?Udp} zWO4_7u;6aR@1YPMB9mV!z92e7zE9=vSNVk@8_rXD2TeVUKNHM2tTOn@K0k#a4f|H< zeW&yaA_LMHB`31p9~4hy@|@yLtf~Z5Jg*ckCUt1hABg{V0A+G)`YGpGiSE{NRXH0g|uE z9}2m7)=~0$N>1eBX{7jqXoEaR={E+l)exmu5GiV6wHR}+c$&i+rgX!BJXu5mX;Tc4 zuIdHkhe((AQ+#2_tO5AJa)W@hAx`NNDIculL#&9s6bw@tBa|YMa{f7kIzRzr=jH)f z;e3Vf1NkY4H1GqZzf|chQ@C8=3LySjR$0-1R=Ap+LN@%dQd|dQ!=D3L@g^Yiw<_GO zaF@cb6y^f)&$1ssSnd!Rh2)QbXS?5W4lwbZQXsN{V@iG;$gGq2!GdRj%>NO{a_1HQ z6Ohy8IuQRXH~2##^>2eG{;K2-%w&QYe*l?xU-6F=GLsEED|7*}VI4@%lmwOpR#JR5 zAm?WtUHIR^L(;Z%Zk7{uu(wqkycgGmy=7 z2eO%7KZd`ZcPT(obhil!-fVaRgRReoW}eEuN_;tV7Adhty9$bf{0 zI{v8$7Ji>U{sLKY0rE=&*8%B*PZfR!q>sM@Qg0iOpMuD8`H-{S?>GkhAn_Dg@O#A* zsdxfN`ALPRR6dc(v-rVDa{)+$ege{v8$g!33FL>!{M(AZ%`xDI0xI4Ca#BA5Qpd{H z|Aef-8FJMzth4Gk<;G`$cNF|Kt6hf ztNi~IGyl^H{%^{2s9K-{@KC1Z1tjQ_)~ZJZksfKQr%s(X^4%?bBOj?K@pWfBwrLrQ3-|aN?saBSC(U@ zLh5@cxhIfAQ&nLdrAK6k>H%q+50K@2%{`C_rC1oUpr6Vwh?EDYiUL)BL1Y6#N?s5t zYNEyhF=emTL5$t2xr)MTm6-yU-5RCweb7~&_<*Z+KOC4J5lWjAX1DYv={k=4x-+-fR17!Axa!4bQ1pG0XmCp zLKkt45G|T~4|q}J5W0#hgl?k6Q9ySw2O!=(%Dx_j;XOp;G5lzEjD0-@K`(KSfAEiDKLt2*#d);4}rpMZL2S)IAHq^s^9* z5XUJvMnSV5AV?9}KR__`2MDfHkSdy-gCO)A1oO{9kS?xJaG8RrA0Zee=KKi3yFWtk zh=MU9@;n6X&O@;JJOty!JqrGypx*@u#*5__AXs(*0@sTWyexWMgrMg|2)0s?DfFKp zaQO*>5kEmNQEa5(3koVpyad6}OAzEyFhzJ?hM>Y_2wuJn!Rw;h6$tiHFy#sa z*&?5Uu~#4n{uzQd#l)W>sQWVn7b%!7{I5cAjDlHLA($b~Q84u?1g)<@kRx)gK@fTk zf?p{xM2qVXT&Cc|>k!NmwF02SS)r>F!VMAweCRh zp-8<0L4`Y7N%8$1t-ZEXRJ#ko-aF8ka#!mR#dl;JALp3w-~Dl4`=}%1d;>Cmtha5< zAD8=I-9Mt}!2Pqwx~7ixi-~P{zRc>&Cxddm<13CTl6UaZPkUCR4ct4V^Tv0)XWy=P zL5#SoRmi;7^T&zlNw)j-O4~QO_FMYui2>PpYc~y#d&}vku>niY&wK1QuYBF5=fbP} zdUV&>gBut9*z_&`n)i!UelYcB(S_Lye}Xxh<#8##@wKv`b(+`7qpxXqkAL3ZcWIH; zy#_389@rwO_V^_aUhe(k`(;*t)hgq|TJ0CF@cguN+b*|T21Wfm^^I4uTkfuQ;iXzP zu21ZL=VMX%SD06>*Ns&ljQr?q2YX`kfC2RfA8>Cx;>eF{UkE)r_p2W9CmPl~6`i=* zCG}X39~abJ{bum%>tX^DXMA#D-mbRocl!5F!!MycHP7)L5gq8fWZw{{_Bk27#~k09 zRJ-5EP$M;dedE;i{`Whro_4a=&H5=ZWrn%k9q9SSZ=-u&tg>xziD4o0GG00LS<78p z+HUz}^vZ0JNAuFAcyF3qrD~NOckXn2sblX_0p}94mmeM-72fVxo0NfzD*E1P)2{f0 z-@eal*yymw!iJ%4DPdPiymh8cgY?xgK?7PmDPZ2(g5K-$@u>cb_I=%bV%G;<%VtG< z;hfwitNV~QqB4F6zp=aJYL6Wg+~;)&-RN1#vvTD2O$qBR-HF(LTYsaN)6Q?#<*$0& zbiH`_H?4v=jY*@e6ZL+@EDHS{vuOIS5PT|*Q*fDrX1_u3naKVPf_MLb;5r2xM3dhk zXm<~S`M*Q3QCy+m4+^6GfMBzj^9KaW?nCg1f~_L*9t1rfK(P8A1lzjMaOiCzy7o_C9Nggruk2>43G622B2344Y6BTd*I!#Bk?RI6(3V}0Al zx!NyyZpL@cS@ZBsH@-E`qURj{=LrSHMT=5a{9cjstiDzkEnmbsTU(M_Wp(j5iWj2r z&n)BkA7?yEZKzV!T9S2(Q&y{s%@23`Ix86uk+xkVI$6(`Giz2Q9sjdR{sz?D*{Xdn z))ld~^t@JA{wYuzG$Nh9FoLgf>*2d-+Agdc7M8L0RX+vN-=cQi@n1ij+t93(6@NBv z)rObdtWHj@?PKI$4DAMVWBC-?H`~MNVx2TVM3l1ng7tU&{r>QQQkyM1{wnSi$mk}@ z9Dmn;`-Tx7);pRuTf|ndma%<1RC((TDib3;(RZ`dpY5HdskX~a8t}~L_)kJol13+3 zzTHr{s&#?2oa2vl<9|%?<)2<BWPHt|igdSSoRaZ%o$G8JpO=)3->`q7WaE{L@1!_hjGAD| zGUXdD6`=^;@-lu9`M%=s>=HhX7cl9Shf0=Z7B-ieC4(-*XQGnvRlC=eY?6}krM1hd z>|`Y?1KE{KC45y0>E>OKQ9MP-$|1c+>AeOS{+X|E9a6HXO0PWRWsoic%m%Xa{CY56 z88cn!c|tZ!$ui%95dX}vOj5#`O3@2)$FJ<h81GKinIm8>$-S4|0C1yr&s;CCz8 zJ4(iR&>X~Joh4GJ@l#(q_dWfc$7nN+0(yI>{ z-?BsKH(xLI05$;e-9wK57fP=o(tPib!@AKixj4%!K!Ib(SBTlLFQ}rDZC0{Ika;QD z7A4~wHhkZn=;1l0_iRZ+Yk;zF^HFcF zW|`)ohY-@LZy;d9EkOLW0~+<6D*OV{zbo1IO4bsxrI696qe|8a>7J|vpJPha8tIZq z(^~c64m_cRZINapl$}(vc1ZJg9$ElTDOn`a{Zu2Tm8`we<4QTBWF3%p zyi|Nv$vQ$7kFqqz@`DmaA)NprD?X=Wosi}m$gG5~lylN={~8H=dL9vLc3P`DAtZ}K zJFRmX^X;{YATLlQP-PHfwKs@w#JPZUP%%((5U+hMJBv4VSt~X0MUu+Dnkk2E74di1z@M2UP%hg7{+YT_;g}x3#v; zU${yGrHkm@)-w7yB*%j$h!MN34Q$y+z9HuCwt8j0jidp32XqM)Foa(L{S3Ma;(N*4 zK|4S@L5$L0gZ6_Cfbu|$(lbG?fEewwKodceK(B(PfL;f+14V)&;M?Y)7FN8u)&_}o zph!>$P%I3I12Loz1`PoX1to$Q(uac>#gjqN7@-$IT|wPI-9h}-HJ)P5Abr*%X7909 zv0X;`irBcv>g|3T$&;YtpxvN7;>sRtqs&!Et_H0Etp#y8F~BgOFfK3-aE)`Na2aw^ zahbIMy#Q(nY6WTy;>6?x=lg`gpiod9SX39pP|iR0&hT9gR1;LoW*KDRch!7D@<$Bh zdC&zAU&(v|vO?|z;t`CmdGgiIZ$W%Zoo}!+?#}|v0lf!WhP>sV#W2DEGTcXrU%#?? zXT~7e1H}8-2#_7r9OMRa2NeSq1r0<|XP^zl1#trAod)rDvG#)wfW8Lp1?7VHvcXQ! zE)ajSY!isT`nCl0A*d~A5n~~L<8L15eb8)>0KEr#jCT0y`7KZ*OoISWAc(K0>!4zw z;-C^BSJ00jz9WAG^exB=;@kiH4S^_7NlH0y2`UA01G$6B zg34*wBe=`_3i=K7JLn$hKIjzaHPGuKcdxap>trOSfW822#O0*5LFP~-6G6j3!$C=) z5ujwyGSG6+5>Pw{4+iF62jF|GHZ)xi^bj)MtNMfZqHTRp15iWIMdafF!tyJ|n{NU0 z7t{D_4)x%Y`XFEarV`)iehD-l#J9fFKGo<(`|A&-c`^w&_K`tPD+s41u-s4}Pu$Qx7@R2{_kH~WI#2Q2^Hh_3u>y9!ppdKLZ$+ba`p`#)12Jj~67N`TL z0jMD;4CKpSyY)k&2511RTnM_4#x|mYO`xry4^YWs&=OEE_{Jdq22o!S_vZef-k^G* z1|Tc+oIw12$BrN$5MSGC3<>}Rf@*?BKzA6oi{YSwpjgl#P#lOG_Yja9Dk=(c1_hxI zzlq)s1Nk(=hlt&vgP?4X9XjnnWk9#kk>;T4aQ7|f&PIMuAWyHAL8U-NK$%BS=?5Sl zVf|5%zdpmG>lx5DAf8w~K|H1MR9YHz3Hnz+KY{S-ouxUbDCj#hI1a=EBM*o?5Iz9$ zg>#o?HvApYjXQeYj_!K2v|q%FXLIy`mpw@#O7*vERI@lsH52=5~! z0WxATFcE}b46~$y(m)xYF`%hOPzaxl%jStmLu5Z|^%gS^T3viTM%Ho=e}ypzG!w)% zm<{5ZXr z?*ZaQ!iDDx3PPIous&`sT$Ee`+^H$!nri}LJ#c<~vjQ^xLE}LEK-?j?Q-p(>f$E@e zZP6m%T0_)4YwecFlT9B`4^UrFZ%~ZldjVfWgFS%`9qKV?u?=>Z5oRbb88jTkU`O4- zpjZ&~*+CjT5I6v|8D&(tOv?}m5rC zBQNV^M`*O8zA;FT=A%X`5-A{BH4@0soB>J)F;sJV(eq5CCxBiCjR(=#3n0q4 zXA&Lq4Dh><$Dw-#a;`N;eux`{tc-DgA`1!a9R7P}>TznZPR8vlswq`RyR47-w1Guw z1Itmyt%Ele+(v957ZA4*uJKVIPTy%D<}pyc2`Uab*X<``%%Pa=H51I#}-dt|pbC7r!#D?Dk za?Z~OEdWt*A&B9A5s0T@Hy}@Zi$SwM)MKUGG#wRxg!G4?C7`7s*0C0}2DB118}+O( zWtbflt}2khOe(DgF-T+g($-Xz7gp! zKpQ}xwZ@OnL9oNJ8MqzvC1@LHCulF|YY;1^hxQ=-mExCOMgpIYMvBJgtsl?6V2uxa z4LqVzR{t6Yrc{sdv$$ye{e1lcef=$Sky8daj{iCeIsU#uzJV6;@&#*WZH>sgVDW# z(OMzc3#PJ)!N={NboU9BRfPBk$<4AXa*E9ko;txNJ_==nP&uA!+KKt7TpYe=t%$E* zuOjW0ifXDs`DX4KpR(19d;{e`U)X}_C%k{MdIoa`VQob&-Y(PKsqAPdz>H96Y(|a; za%QK$=vp#n{7B>k%Xaed^(2aNv!ueq7ybU2=$b1+OsnEoim5)S@ zj+}vRzN4b|H)W6f1ALKZNfOO2StsI~*KL=qf!ZW-=aO}ZHcj-oj0T%wBf-MW>N)Ar z@gMdDC0p5Gpl>jpHUkP;Z?XI`N+pQXm#ss*QlQR8T-~)-)5}j>4|P9tm@Q+)fGf~@ zRXn;0SFgHat?2a@lwF~`X4eO27Td3_fii}Jy`3x0U9q;-mq4Ks6t;^-Kf@M2*pvkO z`Kn`~uTH4C+p78b2BP~o`H9rKu;mx=GWB?eOTC|NcKESVz3z$6^lV}s6tr?;KlQ3Q zo44Ls?lry3bes3^a#@YHdHggIPpCIicwU9gO+?gHsJDbV=ia2ynUgM-aUJ(ey|b7I z1-N1^;X~1AfRm>{d)Iam)sK|QlkrQH$ zlV!iCd=1T=P}W^ryu@#Q)WgM42$TwIMKl!3?7$g{_S|!={#m)^3vXKSb=%9=tkpE- z>hbt)8RqO4*P*Q)6C+wU`4kN`s}LfLS$#$K>*$~Ip7aXdo7hUBDm_UQ{{_ah#T+Pw zwwwld-ubrFs%GX;nQJFT*nffgB$57$H41Mc?0~ui4`)ovN#fUEFt^mO&%9wR?Q#J| zb5uo%U#$V+)D3GFwCQuxI;Jd|X z`NKcyYg*ywiyMhP!v7Z9O%WXcUQ=Ky*Jg$L_x)eo*Eqo$6UDy?W567d35DP_P+&Cj zd*QtS8;X>&K2vb~f0eG~TU~j7&FHV6;& zKX~$e&c+tca>k4Kta}C&Xx63K8MhbNf7|>_VUfs(g0@~bj+@k((2m)I$6}-^i=CU58`^}g$$yp2X4d#$qj*0=l!6|p3;0=Z7 z_kQ`M`NI6i&lE~m#zQ7@ie!8fpLFmJM$u8!Ppn2!ug*|l(a@XYJC}5OJ^z_PvN#6? zZKilYy*1EdRJ9k49-KBcH~yL4*P_wyu;Bs}IAu%ynD5{4;r@ru6lef4v!b0^44h>Lf{bRE|TjrhLq~6FV0B*-eXR=8ruv*zaH0k0YQ>5|!`47{~uY zJMPwJ$4=U}ykbU1-zIQ&QxOdXM11q1=?vHq8k$brm+#Z{3>)7?< zJxdyyb(P*_kat6_sF34_WaV0M3q$o7@Ud5C(x z6DtWni9OH`zK!Y_*_#bH96#apVKvze7>S+H@qaU4i|^h2bolmG$dM7CuZ(d0tv#hm@U<4j9U zcgM_Hgx;cn|5C04{<6hHfm1Z zeAiKP3dGxGx5At}L=CG`v^GeLvN{C@Z@>iLwAlR3PlJ}FuYVmC%i*!?Q8^z3e&1=q zhxbCv9J5P(#Az!`a}&R#2D}uRMLPX?Jg(%b$p;7F==2It zL+Hc5ZuHZjO9xC7UpqNPc{L4}9ew|`%`KNbD)|wL^2CXvtwkeeG}l$cIXgw+U9xq~ zupvh5C;T8TA}@G#gq)hq?rdMT&nTY!NJ3JnxIjMGEFb7ePPTi77?U-&rkXf0uo={lcTHRncaBn5(#>64y~G_-81% z!LVVrOGU~wh+AhWn8%g-$f1|BKe{$STrTlBa@66xnFuQiTg$hW3T`tOlJTQaYLl z$0IqrnefV$F=@TdErkNZ9mc&Qau{zsY`rRXsysQ@s)b^IV9^-S4@JFFp}@ME`giD7 zapKukD5}ohGenM#xw1fpysEst)H0Ba%aVc5~$^hxJu3{Y_42JkGRBV!4}BIsF*4Dx%E* zv8bY>0#%QvDC_PNgw`V6VO&Xkf5QWGu`4|)+ZRr|kLuLqHpkBsFU0M4M-6AhMX2MA z=)lr|Vq#)x2rG)Y0I$Kl<)c8Dcf0;Cz1_ZpawAMyC=Qi|U(mCTmMUxMp*v`jpL9$we%Ku!rI=jD+o_l3p7MvofzxDQWou%7|HrpTnauPEh*7#bhoRFUiF zUmTukopX({-|1-)Hf`SjlJK56~iGldnRYel%@UzXHnt}I#F`Xoi%tmw4f z>(8e;H?8|fT;ZeMHe#C>hA3Lx@j{=9i9VH3+KjAbjBF}qR6^*upDK1&LO3+{<+4S1 zPlkIE_sUOO(&Q{h4hHJE#w&=oD?63ecW2<}j`pI&YLxQoGs=9KDC>HQK0Sjwmqou3 z)y-&tN9S@edFLC}*xlb$z?L3hkx{3r+0trgF-&wj`BL2ml|lzwwfT^6 zcBoLjNxWPI&FzJ>G^C&Ath2h#JZLe;*^IR(#5yQwm&AU;U2(CBQv;XBs#=lN27705 z5$NsIK(9I4VyTKc7KlZ4pt?*<^agAc{sDm9BHBkDXYnR&O$bhjbFE}-xguO!I(b^3 zj26wSWA?a-lhu*GKtxwH%T=oe`}&TN^RV@}7v9^vqqVEqTi$qCZixj|F#wOph%@z2 zTGeh!*(yupcSXVoy+k!%zzC5aXx4nInzTsTCN@?>056zHBMNlosfDUKrP@Jc)-tO@ z2R+61>TrnaLOF4Z?fHmOH2}C}s|Qe3_80vi^cp!qhT!Y*7q0v^b$my&UuJ~J6gey< zZL5OmG#jc6Xr-Lr|8YP-r7ShzaKS2Zwg!T`wCB&Bc;@HN#g0bkFv9yDv6e${Smf5k z)c!#{0$1rb)X#WW_SBneqKD)ti%lGY6`3to4HQZjQv8iz$ys#&XS|> zbL<*hvy6|vQ%%D^b&=!F`hE=ixr31-mwpJ2t#=Ay7IaVZI#+g9DVti)%)3qo5N-UZ z3a!IetP8~~;8~<_j8+Dwh|ff5^_LBl&U>-4s6AD26vhO%|~L zT(ILeT*h8r{EqkIJAqJF&u+bD$vwkwW=!glOC!0*tDJaoy(vsGpXEKOAk_Q-Rl2tB ztur^$%ir7pRdwa|vq%X;E5Ad5`}y~s+AO^~<6O|Qy7)~ruK@PDT&dl@(>~t!aa6Fy zmJnORV5fOe=80eZDjx0>6}DTSmnq})I-kB&zk|i} zaP)htSQ`G+gMVW+n*LdoYF4<_%C^6=x?eNX>KxPnt5=d+BF>Tv6X*Inm8}}UgG}T+ z>9TeoCa*c>U8E??<&6=X`9B`IyNcKd9J*ssLrHYF(jQsH*Kb=j(e$bLu$v;*MxeR1 zA{TnvYVio1*CeR(^!;Xo{EAD0JL8!b*ZY3_%JsR^H-14a!Hy>t#{oKDd}_y7hYPLw z(;kRM&7EwWZ!eG|cjVUP^Dk{X$Y)l5UjX-&Tqy6Y_jPiace%!-)^6Hk=(?eC$FGKM z?3Oitt=qCc+_dV@<T4NU+w_xpTNo+w zo5Ky$pimA9*$;kB8=BXT-*}=|{Ptmy@NNN{&95BG6y+^hfaCt!{MwRVge;=Q^ZS-v z+@SDTeD3st?5NwF$!_Iqr|?!m-OE1P%C3kWFTmGwM-#mpBNFr$+g?CDa`*n5T|_=| zIL_&X#V;Os3Pz0QH~8m6lH8UZ!M>Ad-3p`kn=o3zRW%mN7#j1@XEU1=nZ;X5P63RV zxhZ(!_tDMgz4#^avY}Xbvx#f9nafQ*^IA@BiCP~K*&6pg6QRcol7wkx!rtEh=^)ef zfjp(p6YE;T9UkH$K~Av0d9GV4qT0Y!azB;Naa+aQHh7NPk0$W7MOIUBs0}*OMpSN# z3JW^pPkW8{tSxTTJw(xVf2H?aFcurzL4AoR9*Oo%tIHNKU+XZ#tEbocWpeDxm5l5i zH}q&zbdlc%!et&JJrWiz6+2keB2E$(iC-bC^wkPECoO}&+M4|h`}kmSa)s#M z-l>AuHKj4P*qDvEgZCVQ25)0fuO@QZqo%TACBUoUO1W@Oe%WF1g|~+CyJRcEv&^-$ zW2N}G1D;sT@9WA|HBa(=5luO|_uLbPKMu;918u%r;~!)xDjs!^!`jj8a|eQHw}%Zq zxVA!Mb;SLH`qty?j!sRiQdU8$EK0>YdGOS8eXX2~bIWb`x#E;9!%@Auy*CZ^^y>7n zbmFu-+)Fms|rW(4T03o z=zNNJ6HxdY0(g-R1WbiO-w-f!#*5Redxo2I;d5^Ymt8b89Z*fIW)o|~t?o|cY}{5iiF1%x^S=-eqp{2`;f;Pi z;WzurrEXW3uRpVD2qxx7mhonpDI4WPueIl+u64YIIX{~w>bseR;^i07g+-{0J7}qv zZBCYIcAej3hF~rF;cYvy4hpctgwKS3FW7MFe*$}2|e<4f!3IdY!%Rt~%zLjFR_zjoC$)mxl} z4}<-8%5S}{u1xsw{cjdl896-Nro4No)GJdv3gqCsHh-K>b_t)JIL~_Sk_v%u z$Gx;;{z81OQvwpsvRc5hz^h@gvAF@AbQERy4Gs)$BG&fAh_n&8JrRZFJ5XMKZplqs zhYENgettUs>m7DuUvtC5MX=Tz)oQ}EAD`?}M3>%90ph1#xDR$bk|-7P`q+FNk^-fi zg$*d5RZJ3B3D0F#2(Hmqz7BJ4>dB0Co%XqyzA$gw{@SJhF`^Hy1H+Te-#*SN+G+fl zk@bFg!KyioR%XgC1pS;5lI8b(Vo9P?xgx>5<&#GJ%Nv|-TIanoIQ;9O)X`u0rL1Ot z3xNIxShk35_?EViF9IC)6m(+ir+$+@g?mQz!-z?PIu9Dsuj{$>ootHgde9X&dhxUv4r@p*hLsA@ncVyOCh z+Wb~oz54O|>khF4ow|By2V~=6pHJ~Qe{-ZehtA*pW^0l-G!X6d7L{Ys_&5DAXQ8k@rN23^b zs@ZN?Edu+kX^moh776gT#EBhHz{+4NUJs$iBg?B#K70Q`g>rnuK^?@+^AX-fIxO4o zwe0HGtri^^0|g%Y(Ej**`BjVW$j?)+cOQIO)!WexdUm2e@T0?^_Qpz|*RJq9W&-=vDM znFA97WWo{H&}F!H1>$y7m5ClgGSeZk3S2e~TuK6!iA;Ih*_7lZV0%t1XDax6Xe{m7wdp=-%tlOqu1#-9W0qiIyFPsuh|hn0`Z*vC zT&u;H&J0|G1(B9Xxegh`U~hjEf7|n|0MO&W8XOdxdDo}6q%-?K`T&wlJ~yVnNe7x2 zb7MMN2D6cA+6_qb#BP;H*jo?`TB`ybSy>5`0aeY{Hs9)IDB_3A$$&iuS#u`Ca|;r6 zlP|w!-gof-S{C5MG`K^?aOT$ZMHxUld#1n70Orjlccyb?GW*D&Pp1Oy0%l2&Z+G39 zUIbLZ^`U?nn9P8?o{FZQ$Yd@6o`hzZ#VlXHTnUm(r*4zoAlAM?brY0dxpSLF;F%9M z_5%6*K&SdSg`X=rWZ8cPDsRDQxoFX9SH0^{`3-l=g&kd_Pd)?kL5VI$X@XeY!R$p} zfP5h!e|r0Yr6)ETo?}yn)JLl>y_>f-UQ6kK3{XH6sNl@Y`-iI2C!aC|@hy;;EOH7E$AJY<>;b6&T4p}oVIi}kB-{v~8aNv$ zZ9H8Nc-)d8B$EK;APimO=^H;YD+@yN8CVb40n-I8GOKC8eTd{npc*6|&}1N-&e*}M apo#Djl0TtVK|QDoTDxJfz2AZ5F*^XG4>uwJ delta 43967 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 base64Replacements[char], + ); + + if (transformationType === "toRegular") { + // Add padding with '=' if necessary for regular base64 + while (transformedString.length % 4 !== 0) { + transformedString += "="; + } + } + return transformedString; +} + +export function uint8ArrayToUrlSafeBase64(array: Uint8Array) { + let token = ""; + for (let element of array) { + token += String.fromCodePoint(element); + } + return transformBase64String(globalThis.btoa(token), "toUrlSafe"); +} + +export function urlSafeBase64ToArrayBuffer(urlSafeBase64: string) { + let binaryString = globalThis.atob( + transformBase64String(urlSafeBase64, "toRegular"), + ); + let bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.codePointAt(i)!; + } + return bytes.buffer; +} diff --git a/src/server/csrf.ts b/src/server/csrf.ts index a045930..6f8d219 100644 --- a/src/server/csrf.ts +++ b/src/server/csrf.ts @@ -1,6 +1,8 @@ import type { Cookie } from "@remix-run/server-runtime"; -import cryptoJS from "crypto-js"; import { getHeaders } from "./get-headers.js"; +import { uint8ArrayToUrlSafeBase64 } from "../helpers/base64.js"; + +const textEncoder = new globalThis.TextEncoder(); export type CSRFErrorCode = | "missing_token_in_cookie" @@ -28,20 +30,27 @@ interface CSRFOptions { */ formDataKey?: string; /** - * A secret to use for signing the CSRF token. + * HMAC SHA-256 secret key for signing the CSRF token, a string with 256 bits (32 bytes) of entropy */ secret?: string; + /** + * Optional WebCrypto polyfill for enviroments without native support. + */ + webCrypto?: Crypto; } export class CSRF { private cookie: Cookie; private formDataKey = "csrf"; + private key?: CryptoKey; private secret?: string; + private webCrypto: Crypto; constructor(options: CSRFOptions) { this.cookie = options.cookie; this.formDataKey = options.formDataKey ?? "csrf"; this.secret = options.secret; + this.webCrypto = options.webCrypto ?? globalThis.crypto; } /** @@ -50,12 +59,12 @@ export class CSRF { * @param bytes The number of bytes used to generate the token * @returns A random string in Base64URL */ - generate(bytes = 32) { - let token = cryptoJS.lib.WordArray.random(bytes).toString( - cryptoJS.enc.Base64url, + async generate(bytes = 32) { + let token = uint8ArrayToUrlSafeBase64( + this.webCrypto.getRandomValues(new Uint8Array(bytes)), ); if (!this.secret) return token; - let signature = this.sign(token); + let signature = await this.sign(token); return [token, signature].join("."); } @@ -80,7 +89,9 @@ export class CSRF { let headers = getHeaders(requestOrHeaders); let existingToken = await this.cookie.parse(headers.get("cookie")); let token = - typeof existingToken === "string" ? existingToken : this.generate(bytes); + typeof existingToken === "string" + ? existingToken + : await this.generate(bytes); let cookie = existingToken ? null : await this.cookie.serialize(token); return [token, cookie] as const; } @@ -133,7 +144,7 @@ export class CSRF { ); } - if (this.verifySignature(cookie) === false) { + if ((await this.verifySignature(cookie)) === false) { throw new CSRFError( "tampered_token_in_cookie", "Tampered CSRF token in cookie.", @@ -169,17 +180,35 @@ export class CSRF { return this.cookie.parse(headers.get("cookie")); } - private sign(token: string) { + private async getKey() { + if (!this.key) { + this.key = await this.webCrypto.subtle.importKey( + "raw", + textEncoder.encode(this.secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + } + return this.key; + } + + private async sign(token: string) { if (!this.secret) return token; - return cryptoJS - .HmacSHA256(token, this.secret) - .toString(cryptoJS.enc.Base64url); + + let signature = await this.webCrypto.subtle.sign( + "HMAC", + await this.getKey(), + textEncoder.encode(token), + ); + + return uint8ArrayToUrlSafeBase64(new Uint8Array(signature)); } - private verifySignature(token: string) { + private async verifySignature(token: string) { if (!this.secret) return true; let [value, signature] = token.split("."); - let expectedSignature = this.sign(value); + let expectedSignature = await this.sign(value); return signature === expectedSignature; } } diff --git a/src/server/honeypot.ts b/src/server/honeypot.ts index 58900c7..4db5486 100644 --- a/src/server/honeypot.ts +++ b/src/server/honeypot.ts @@ -1,4 +1,10 @@ -import CryptoJS from "crypto-js"; +import { + uint8ArrayToUrlSafeBase64, + urlSafeBase64ToArrayBuffer, +} from "../helpers/base64.js"; + +const textEncoder = new globalThis.TextEncoder(); +const textDecoder = new globalThis.TextDecoder(); export interface HoneypotInputProps { nameFieldName: string; @@ -10,30 +16,42 @@ export interface HoneypotConfig { randomizeNameFieldName?: boolean; nameFieldName?: string; validFromFieldName?: string | null; + /** + * HKDF SHA-256 secret key for signing the honeypot, a string with 256 bits (32 bytes) of entropy + */ encryptionSeed?: string; + /** + * Optional WebCrypto polyfill for enviroments without native support. + */ + webCrypto?: Crypto; } export class SpamError extends Error {} -const DEFAULT_NAME_FIELD_NAME = "name__confirm"; -const DEFAULT_VALID_FROM_FIELD_NAME = "from__confirm"; +let DEFAULT_NAME_FIELD_NAME = "name__confirm"; +let DEFAULT_VALID_FROM_FIELD_NAME = "from__confirm"; export class Honeypot { - private generatedEncryptionSeed = this.randomValue(); + private config: HoneypotConfig; + private masterKey?: CryptoKey; + private webCrypto: Crypto; - constructor(protected config: HoneypotConfig = {}) {} + constructor(protected honeypotConfig: HoneypotConfig = {}) { + this.webCrypto = honeypotConfig.webCrypto ?? globalThis.crypto; + this.config = honeypotConfig as HoneypotConfig; + } - public getInputProps({ + public async getInputProps({ validFromTimestamp = Date.now(), - } = {}): HoneypotInputProps { + } = {}): Promise { return { - nameFieldName: this.nameFieldName, + nameFieldName: await this.getNameFieldName(), validFromFieldName: this.validFromFieldName, - encryptedValidFrom: this.encrypt(validFromTimestamp.toString()), + encryptedValidFrom: await this.encrypt(validFromTimestamp.toString()), }; } - public check(formData: FormData) { + public async check(formData: FormData) { let nameFieldName = this.config.nameFieldName ?? DEFAULT_NAME_FIELD_NAME; if (this.config.randomizeNameFieldName) { let actualName = this.getRandomizedNameFieldName(nameFieldName, formData); @@ -55,7 +73,7 @@ export class Honeypot { if (!validFrom) throw new SpamError("Missing honeypot valid from input"); - let time = this.decrypt(validFrom as string); + let time = await this.decrypt(validFrom as string); if (!time) throw new SpamError("Invalid honeypot valid from input"); if (!this.isValidTimeStamp(Number(time))) { throw new SpamError("Invalid honeypot valid from input"); @@ -66,10 +84,10 @@ export class Honeypot { } } - protected get nameFieldName() { + protected async getNameFieldName() { let fieldName = this.config.nameFieldName ?? DEFAULT_NAME_FIELD_NAME; if (!this.config.randomizeNameFieldName) return fieldName; - return `${fieldName}_${this.randomValue()}`; + return `${fieldName}_${await this.randomValue()}`; } protected get validFromFieldName() { @@ -79,10 +97,6 @@ export class Honeypot { return this.config.validFromFieldName; } - protected get encryptionSeed() { - return this.config.encryptionSeed ?? this.generatedEncryptionSeed; - } - protected getRandomizedNameFieldName( nameFieldName: string, formData: FormData, @@ -103,18 +117,99 @@ export class Honeypot { ); } - protected randomValue() { - return CryptoJS.lib.WordArray.random(128 / 8).toString(); + /** + * Derive keys using HKDF because AES-GCM alone can only encrypt ~1 billion messages with the same secret + */ + protected async getMasterKey() { + if (!this.masterKey) { + this.masterKey = await this.webCrypto.subtle.importKey( + "raw", + textEncoder.encode( + this.honeypotConfig.encryptionSeed ?? (await this.randomValue()), + ), + { + name: "HKDF", + hash: "SHA-256", + }, + false, + ["deriveKey"], + ); + } + return this.masterKey; } - protected encrypt(value: string) { - return CryptoJS.AES.encrypt(value, this.encryptionSeed).toString(); + protected async randomValue() { + let array = new Uint8Array(32); // 256 bits + this.webCrypto.getRandomValues(array); + return uint8ArrayToUrlSafeBase64(array); } - protected decrypt(value: string) { - return CryptoJS.AES.decrypt(value, this.encryptionSeed).toString( - CryptoJS.enc.Utf8, + protected async encrypt(value: string) { + // Generate a unique salt for each encryption + let salt = this.webCrypto.getRandomValues(new Uint8Array(16)); + + // Derive a key using HKDF + let derivedKey = await this.webCrypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: salt, + info: new ArrayBuffer(0), + }, + await this.getMasterKey(), + { name: "AES-GCM", length: 256 }, + false, + ["encrypt"], + ); + + let iv = this.webCrypto.getRandomValues(new Uint8Array(12)); // Generate a new IV + let encoded = textEncoder.encode(value); + let encrypted = await this.webCrypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + derivedKey, + encoded, + ); + + let saltIvAndEncrypted = new Uint8Array( + salt.byteLength + iv.byteLength + encrypted.byteLength, ); + saltIvAndEncrypted.set(new Uint8Array(salt), 0); + saltIvAndEncrypted.set(new Uint8Array(iv), salt.byteLength); + saltIvAndEncrypted.set( + new Uint8Array(encrypted), + salt.byteLength + iv.byteLength, + ); + + return uint8ArrayToUrlSafeBase64(saltIvAndEncrypted); + } + + protected async decrypt(value: string) { + let saltIvAndEncrypted = urlSafeBase64ToArrayBuffer(value); + let salt = saltIvAndEncrypted.slice(0, 16); // Extract the salt (first 16 bytes) + let iv = saltIvAndEncrypted.slice(16, 28); // Extract the IV (next 12 bytes) + let encrypted = saltIvAndEncrypted.slice(28); // Extract the encrypted data + + // Derive the key using the same HKDF parameters and salt + let derivedKey = await this.webCrypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(salt), + info: new ArrayBuffer(0), + }, + await this.getMasterKey(), + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"], + ); + + let decrypted = await this.webCrypto.subtle.decrypt( + { name: "AES-GCM", iv: new Uint8Array(iv) }, + derivedKey, + encrypted, + ); + + return textDecoder.decode(decrypted); } protected isFuture(timestamp: number) { diff --git a/test/server/csrf.test.ts b/test/server/csrf.test.ts index 8705a99..5fa7484 100644 --- a/test/server/csrf.test.ts +++ b/test/server/csrf.test.ts @@ -1,27 +1,43 @@ import { describe, test, expect } from "vitest"; import { createCookie } from "@remix-run/node"; +import { Crypto } from "@peculiar/webcrypto"; import { CSRF, CSRFError } from "../../src/server/csrf"; describe("CSRF", () => { let cookie = createCookie("csrf", { secrets: ["s3cr3t"] }); let csrf = new CSRF({ cookie }); - test("generates a new authenticity token with the default size", () => { - let token = csrf.generate(); + test("generates a new authenticity token with the default size", async () => { + let token = await csrf.generate(); expect(token).toStrictEqual(expect.any(String)); expect(token).toHaveLength(43); }); - test("generates a new authenticity token with the given size", () => { - let token = csrf.generate(64); + test("generates a new authenticity token with the given size", async () => { + let token = await csrf.generate(64); expect(token).toStrictEqual(expect.any(String)); expect(token).toHaveLength(86); }); - test("generates a new signed authenticity token", () => { + test("generates a new signed authenticity token", async () => { let csrf = new CSRF({ cookie, secret: "my-secret" }); - let token = csrf.generate(); + let token = await csrf.generate(); + let [value, signature] = token.split("."); + + expect(token).toHaveLength(87); + expect(value).toHaveLength(43); + expect(signature).toHaveLength(43); + }); + + test("generates a new signed authenticity token with WebCrypto polyfill", async () => { + let csrf = new CSRF({ + cookie, + secret: "my-secret", + webCrypto: new Crypto(), + }); + + let token = await csrf.generate(); let [value, signature] = token.split("."); expect(token).toHaveLength(87); @@ -30,7 +46,21 @@ describe("CSRF", () => { }); test("verify tokens using FormData and Headers", async () => { - let token = csrf.generate(); + let token = await csrf.generate(); + + let headers = new Headers({ + cookie: await cookie.serialize(token), + }); + + let formData = new FormData(); + formData.set("csrf", token); + + await expect(csrf.validate(formData, headers)).resolves.toBeUndefined(); + }); + + test("verify tokens using FormData and Headers with WebCrypto polyfill", async () => { + let csrf = new CSRF({ cookie, webCrypto: new Crypto() }); + let token = await csrf.generate(); let headers = new Headers({ cookie: await cookie.serialize(token), @@ -43,7 +73,7 @@ describe("CSRF", () => { }); test("verify tokens using Request", async () => { - let token = csrf.generate(); + let token = await csrf.generate(); let headers = new Headers({ cookie: await cookie.serialize(token), @@ -89,7 +119,7 @@ describe("CSRF", () => { }); test('throws "Can\'t find CSRF token in body" if CSRF token is not in body', async () => { - let token = csrf.generate(); + let token = await csrf.generate(); let headers = new Headers({ cookie: await cookie.serialize(token), @@ -103,7 +133,7 @@ describe("CSRF", () => { }); test("throws \"Can't verify CSRF token authenticity\" if CSRF token in body doesn't match CSRF token in cookie", async () => { - let token = csrf.generate(); + let token = await csrf.generate(); let headers = new Headers({ cookie: await cookie.serialize(token), @@ -123,14 +153,14 @@ describe("CSRF", () => { test('throws "Tampered CSRF token in cookie" if the CSRF token in cookie is changed', async () => { let securetCSRF = new CSRF({ cookie, secret: "my-secret" }); - let token = securetCSRF.generate(); + let token = await securetCSRF.generate(); let formData = new FormData(); formData.set("csrf", token); let headers = new Headers({ cookie: await cookie.serialize( - [csrf.generate(), token.split(".").at(1)].join("."), + [await csrf.generate(), token.split(".").at(1)].join("."), ), }); @@ -152,7 +182,7 @@ describe("CSRF", () => { }); test("does not return a cookie header if there's already a value", async () => { - let originalToken = csrf.generate(); + let originalToken = await csrf.generate(); let headers = new Headers({ cookie: await cookie.serialize(originalToken), }); diff --git a/test/server/honeypot.test.ts b/test/server/honeypot.test.ts index 36c5069..7cd9d4a 100644 --- a/test/server/honeypot.test.ts +++ b/test/server/honeypot.test.ts @@ -1,15 +1,71 @@ -import { describe } from "vitest"; -import CryptoJS from "crypto-js"; - +import { describe, expect, test } from "vitest"; +import { Crypto } from "@peculiar/webcrypto"; import { Honeypot, SpamError } from "../../src/server/honeypot"; +import { uint8ArrayToUrlSafeBase64 } from "../../src/helpers/base64"; function invariant(condition: any, message: string): asserts condition { if (!condition) throw new Error(message); } -describe(Honeypot.name, () => { - test("generates input props", () => { - let props = new Honeypot().getInputProps(); +async function encryptForTest( + value, + encryptionSeed, + webCrypto: Crypto = globalThis.crypto, +) { + let textEncoder = new globalThis.TextEncoder(); + + // Convert the seed to a raw key + let rawKey = textEncoder.encode(encryptionSeed); + let masterKey = await webCrypto.subtle.importKey( + "raw", + rawKey, + { name: "HKDF", hash: "SHA-256" }, + false, + ["deriveKey"], + ); + + // Derive a key for encryption + let salt = webCrypto.getRandomValues(new Uint8Array(16)); + let derivedKey = await webCrypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: salt, + info: new ArrayBuffer(0), + }, + masterKey, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt"], + ); + + // Encrypt the value + let iv = webCrypto.getRandomValues(new Uint8Array(12)); + let encoded = textEncoder.encode(value); + let encrypted = await webCrypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + derivedKey, + encoded, + ); + + // Combine salt, iv, and encrypted data + let saltIvAndEncrypted = new Uint8Array( + salt.byteLength + iv.byteLength + encrypted.byteLength, + ); + saltIvAndEncrypted.set(new Uint8Array(salt), 0); + saltIvAndEncrypted.set(new Uint8Array(iv), salt.byteLength); + saltIvAndEncrypted.set( + new Uint8Array(encrypted), + salt.byteLength + iv.byteLength, + ); + + // Convert to base64 + return uint8ArrayToUrlSafeBase64(saltIvAndEncrypted); +} + +describe(Honeypot.name, async () => { + test("generates input props", async () => { + let props = await new Honeypot().getInputProps(); expect(props).toEqual({ nameFieldName: "name__confirm", validFromFieldName: "from__confirm", @@ -17,127 +73,152 @@ describe(Honeypot.name, () => { }); }); - test("uses randomized nameFieldName", () => { + test("uses randomized nameFieldName", async () => { let honeypot = new Honeypot({ randomizeNameFieldName: true }); - let props = honeypot.getInputProps(); + let props = await honeypot.getInputProps(); expect(props.nameFieldName.startsWith("name__confirm_")).toBeTruthy(); }); - test("uses randomized nameFieldName with prefix", () => { + test("uses randomized nameFieldName with prefix", async () => { let honeypot = new Honeypot({ randomizeNameFieldName: true, nameFieldName: "prefix", }); - let props = honeypot.getInputProps(); + let props = await honeypot.getInputProps(); expect(props.nameFieldName.startsWith("prefix_")).toBeTruthy(); }); - test("checks validity on FormData", () => { + test("checks validity on FormData", async () => { let formData = new FormData(); - let result = new Honeypot().check(formData); + let result = await new Honeypot().check(formData); expect(result).toBeUndefined(); }); - test("checks validity of FormData with encrypted timestamp and randomized field name", () => { + test("checks validity of FormData with encrypted timestamp and randomized field name", async () => { let honeypot = new Honeypot({ randomizeNameFieldName: true }); - let props = honeypot.getInputProps(); + let props = await honeypot.getInputProps(); invariant(props.validFromFieldName, "validFromFieldName is null"); let formData = new FormData(); formData.set(props.nameFieldName, ""); formData.set(props.validFromFieldName, props.encryptedValidFrom); - expect(honeypot.check(formData)).toBeUndefined(); + expect(await honeypot.check(formData)).toBeUndefined(); }); - test("fails validity check if input is not present", () => { + test("fails validity check if input is not present", async () => { let honeypot = new Honeypot(); - let props = honeypot.getInputProps(); + let props = await honeypot.getInputProps(); invariant(props.validFromFieldName, "validFromFieldName is null"); let formData = new FormData(); formData.set(props.validFromFieldName, props.encryptedValidFrom); - expect(() => honeypot.check(formData)).toThrowError( + expect(async () => await honeypot.check(formData)).rejects.toThrow( new SpamError("Missing honeypot input"), ); }); - test("fails validity check if input is not empty", () => { + test("fails validity check if input is not empty", async () => { let honeypot = new Honeypot(); - let props = honeypot.getInputProps(); + let props = await honeypot.getInputProps(); let formData = new FormData(); formData.set(props.nameFieldName, "not empty"); - expect(() => honeypot.check(formData)).toThrowError( + expect(async () => await honeypot.check(formData)).rejects.toThrow( new SpamError("Honeypot input not empty"), ); }); - test("fails if valid from timestamp is missing", () => { + test("fails if valid from timestamp is missing", async () => { let honeypot = new Honeypot(); - let props = honeypot.getInputProps(); + let props = await honeypot.getInputProps(); let formData = new FormData(); formData.set(props.nameFieldName, ""); - expect(() => honeypot.check(formData)).toThrowError( + expect(async () => await honeypot.check(formData)).rejects.toThrow( new SpamError("Missing honeypot valid from input"), ); }); - test("fails if the timestamp is not valid", () => { + test("fails if the timestamp is not valid", async () => { let honeypot = new Honeypot({ encryptionSeed: "SEED", }); - let props = honeypot.getInputProps(); + let props = await honeypot.getInputProps(); invariant(props.validFromFieldName, "validFromFieldName is null"); let formData = new FormData(); formData.set(props.nameFieldName, ""); formData.set( props.validFromFieldName, - CryptoJS.AES.encrypt("invalid", "SEED").toString(), + await encryptForTest("invalid", "SEED"), ); - expect(() => honeypot.check(formData)).toThrowError( + expect(async () => await honeypot.check(formData)).rejects.toThrow( new SpamError("Invalid honeypot valid from input"), ); }); - test("fails if valid from timestamp is in the future", () => { + test("fails if valid from timestamp is in the future", async () => { + let honeypot = new Honeypot({ + encryptionSeed: "SEED", + }); + + let props = await honeypot.getInputProps(); + invariant(props.validFromFieldName, "validFromFieldName is null"); + + let formData = new FormData(); + formData.set(props.nameFieldName, ""); + formData.set( + props.validFromFieldName, + await encryptForTest((Date.now() + 10_000).toString(), "SEED"), + ); + + expect(async () => await honeypot.check(formData)).rejects.toThrow( + new SpamError("Honeypot valid from is in future"), + ); + }); + + test("fails if valid from timestamp is in the future with WebCrypto polyfill", async () => { let honeypot = new Honeypot({ encryptionSeed: "SEED", + webCrypto: new Crypto(), }); - let props = honeypot.getInputProps(); + let props = await honeypot.getInputProps(); invariant(props.validFromFieldName, "validFromFieldName is null"); let formData = new FormData(); formData.set(props.nameFieldName, ""); formData.set( props.validFromFieldName, - CryptoJS.AES.encrypt((Date.now() + 10_000).toString(), "SEED").toString(), + await encryptForTest( + (Date.now() + 10_000).toString(), + "SEED", + new Crypto(), + ), ); - expect(() => honeypot.check(formData)).toThrowError( + expect(async () => await honeypot.check(formData)).rejects.toThrow( new SpamError("Honeypot valid from is in future"), ); }); - test("does not check for valid from timestamp if it's set to null", () => { + test("does not check for valid from timestamp if it's set to null", async () => { let honeypot = new Honeypot({ validFromFieldName: null, }); - let props = honeypot.getInputProps(); + let props = await honeypot.getInputProps(); expect(props.validFromFieldName).toBeNull(); let formData = new FormData(); formData.set(props.nameFieldName, ""); - expect(() => honeypot.check(formData)).not.toThrow(); + expect(async () => await honeypot.check(formData)).not.toThrow(); }); });