Skip to content

Commit dc2e536

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23198-orphan-adoption-reconcile
2 parents 7853bd6 + 4f5679c commit dc2e536

2 files changed

Lines changed: 239 additions & 46 deletions

File tree

‎packages/plugins/plugin-sharing/src/share-link-password-webcontainer.test.ts‎

Lines changed: 85 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,66 @@
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';
1919
import * as nodeCrypto from 'node:crypto';
2020
import { 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+
*/
2239
vi.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

2780
const 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. */
46106
const 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
*/
51112
const 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+
58137
afterEach(() => {
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(/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/);
81155
expect(nativeHash).toMatch(/^scrypt\$[0-9a-f]{32}\$[0-9a-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

Comments
 (0)