1515 * (compared directly against `node:crypto`).
1616 */
1717
18- import { describe , it , expect , vi , afterEach } from 'vitest' ;
18+ import { describe , it , expect , vi , afterEach , beforeAll } from 'vitest' ;
1919import * as nodeCrypto from 'node:crypto' ;
2020import { hashShareLinkPassword , verifyShareLinkPassword } from './share-link-password.js' ;
2121
22+ /**
23+ * [#23184] Each implementation derives a given input once per file.
24+ *
25+ * Every derivation here is at the production parameters, the pure-JS one at
26+ * about twice the native cost, and the file sat at 7-10.5 s of wall in the
27+ * hourly full battery. So each scrypt answers an input IT has already derived
28+ * in this file with the bytes it produced, and runs for any other input; the
29+ * key is every input (password, salt and cost parameters). scrypt is a pure
30+ * function, so each answer is the one a fresh call would give.
31+ *
32+ * ⛔ The two memos are SEPARATE, and must stay so. What this file pins is that
33+ * two implementations agree, so neither may ever be answered from the other's
34+ * work: a native verification of a pure-JS hash is a native derivation, and
35+ * the reverse. Only a repeat on the SAME path is answered from memory, such as
36+ * the second spelling of the NFKC case, whose normalised input is the one the
37+ * first spelling already derived (a path that skipped NFKC would miss).
38+ */
2239vi . mock ( 'node:crypto' , async ( importOriginal ) => {
2340 const actual = await importOriginal < typeof import ( 'node:crypto' ) > ( ) ;
24- return { ...actual , scrypt : vi . fn ( actual . scrypt ) } ;
41+ const derived = new Map < string , Buffer > ( ) ;
42+ function scrypt ( password : unknown , salt : unknown , keylen : number , options : unknown , callback ?: unknown ) : void {
43+ const [ opts , done ] = typeof options === 'function' ? [ { } , options ] : [ options ?? { } , callback ] ;
44+ const reply = done as ( err : Error | null , key : Buffer ) => void ;
45+ const key = JSON . stringify ( [ password , salt , keylen , opts ] ) ;
46+ const hit = derived . get ( key ) ;
47+ if ( hit ) {
48+ process . nextTick ( reply , null , Buffer . from ( hit ) ) ;
49+ return ;
50+ }
51+ actual . scrypt (
52+ password as import ( 'node:crypto' ) . BinaryLike ,
53+ salt as import ( 'node:crypto' ) . BinaryLike ,
54+ keylen ,
55+ opts as import ( 'node:crypto' ) . ScryptOptions ,
56+ ( err , out ) => {
57+ if ( ! err ) derived . set ( key , Buffer . from ( out ) ) ;
58+ reply ( err , out ) ;
59+ } ,
60+ ) ;
61+ }
62+ // The spy wraps the memo, so it still records every call the module makes.
63+ return { ...actual , scrypt : vi . fn ( scrypt ) } ;
64+ } ) ;
65+
66+ vi . mock ( '@noble/hashes/scrypt.js' , async ( importOriginal ) => {
67+ const actual = await importOriginal < typeof import ( '@noble/hashes/scrypt.js' ) > ( ) ;
68+ const derived = new Map < string , Uint8Array > ( ) ;
69+ const scryptAsync : typeof actual . scryptAsync = async ( password , salt , opts ) => {
70+ const key = JSON . stringify ( [ password , salt , opts ] ) ;
71+ const hit = derived . get ( key ) ;
72+ if ( hit ) return new Uint8Array ( hit ) ;
73+ const out = await actual . scryptAsync ( password , salt , opts ) ;
74+ derived . set ( key , new Uint8Array ( out ) ) ;
75+ return out ;
76+ } ;
77+ return { ...actual , scryptAsync } ;
2578} ) ;
2679
2780const PASSWORD = 'Ünïcödé pass 21839' ;
@@ -41,12 +94,20 @@ const nodeScrypt = vi.mocked(nodeCrypto.scrypt);
4194 * keep the default, which already leaves them 3.4x. When a case's derivation
4295 * count changes, re-size it from a fresh reading, never by lowering the
4396 * parameters.
97+ *
98+ * [#23184] Both counts below fell (the shared hashes and the memos above). No
99+ * `Test Core` reading of the new shapes exists yet, so each budget stands as
100+ * sized for its larger count until one re-sizes it.
101+ */
102+ /**
103+ * Was 3 pure-JS + 3 native derivations, CI readings 4136 and 3688 ms; now
104+ * 2 + 2, the two mints moved to the shared hashes.
44105 */
45- /** 3 pure-JS + 3 native derivations; CI readings 4136 and 3688 ms. */
46106const CROSS_VERIFY_BUDGET_MS = 13_000 ;
47107/**
48- * 4 pure-JS + 4 native; CI timed out at 5030 and 5039 ms, so the reading is
49- * projected: 4/3 of the 3 + 3 case's 4136 ms in the same run, 5515 ms.
108+ * Was 4 pure-JS + 4 native, CI timed out at 5030 and 5039 ms, so the reading
109+ * was projected: 4/3 of the 3 + 3 case's 4136 ms in the same run, 5515 ms.
110+ * Now 3 + 3: each path's memo answers the second spelling.
50111 */
51112const NFKC_BUDGET_MS = 17_000 ;
52113
@@ -55,6 +116,24 @@ function onWebContainer(on: boolean) {
55116 vi . stubEnv ( 'SHELL' , on ? '/bin/jsh' : '/bin/bash' ) ;
56117}
57118
119+ /**
120+ * [#23184] PASSWORD's hash minted on each path, once per file: case 2
121+ * cross-verifies both, and case 4 compares the pure-JS one against
122+ * `node:crypto`. Only the native mint may reach `node:crypto.scrypt`, so a
123+ * pure-JS hash that was not really pure-JS cannot reach either case.
124+ */
125+ let pureJsHash : string ;
126+ let nativeHash : string ;
127+ beforeAll ( async ( ) => {
128+ onWebContainer ( true ) ;
129+ pureJsHash = await hashShareLinkPassword ( PASSWORD ) ;
130+ onWebContainer ( false ) ;
131+ nativeHash = await hashShareLinkPassword ( PASSWORD ) ;
132+ expect ( nodeScrypt ) . toHaveBeenCalledTimes ( 1 ) ;
133+ vi . unstubAllEnvs ( ) ;
134+ nodeScrypt . mockClear ( ) ;
135+ } ) ;
136+
58137afterEach ( ( ) => {
59138 vi . unstubAllEnvs ( ) ;
60139 nodeScrypt . mockClear ( ) ;
@@ -72,11 +151,6 @@ describe('[#21839] share-link password: node:crypto and pure-JS scrypt are inter
72151 } ) ;
73152
74153 it ( 'a hash minted on WebContainer verifies natively, and the reverse' , async ( ) => {
75- onWebContainer ( true ) ;
76- const pureJsHash = await hashShareLinkPassword ( PASSWORD ) ;
77- onWebContainer ( false ) ;
78- const nativeHash = await hashShareLinkPassword ( PASSWORD ) ;
79-
80154 expect ( pureJsHash ) . toMatch ( / ^ s c r y p t \$ [ 0 - 9 a - f ] { 32 } \$ [ 0 - 9 a - f ] { 128 } $ / ) ;
81155 expect ( nativeHash ) . toMatch ( / ^ s c r y p t \$ [ 0 - 9 a - f ] { 32 } \$ [ 0 - 9 a - f ] { 128 } $ / ) ;
82156
@@ -119,9 +193,7 @@ describe('[#21839] share-link password: node:crypto and pure-JS scrypt are inter
119193 } , NFKC_BUDGET_MS ) ;
120194
121195 it ( 'the pure-JS key is byte-identical to node:crypto for the same password and salt' , async ( ) => {
122- onWebContainer ( true ) ;
123- const hash = await hashShareLinkPassword ( PASSWORD ) ;
124- const [ , saltHex , keyHex ] = hash . split ( '$' ) ;
196+ const [ , saltHex , keyHex ] = pureJsHash . split ( '$' ) ;
125197 const expected = nodeCrypto
126198 . scryptSync ( PASSWORD . normalize ( 'NFKC' ) , saltHex ! , 64 , { N : 16384 , r : 16 , p : 1 , maxmem : 128 * 16384 * 16 * 2 } )
127199 . toString ( 'hex' ) ;
0 commit comments