Skip to content

Commit f5b8e29

Browse files
fix(plugin-sharing): share-link password never leaves the server, is stored with the platform slow hash, and is accepted in a header (#21890)
Fixes #21839 Clause-②: no Server half of the share-link password card. The console transport (sending the password in a header) is the separate objectui card; this PR keeps the query parameter working so the current console is unaffected until that lands. ## What changed All in `packages/plugins/plugin-sharing/src`, plus one changeset and a regenerated census page. 1. **The stored hash never leaves the server.** `ShareLinkService.createLink` returned the row it had just inserted, hash included, and both mounts of `POST /api/v1/share-links` (this plugin's route and the runtime dispatcher twin) answer with that return value. It now returns the row through one exit projection, `withoutPasswordHash`. `listLinks` and `resolveToken` pass their results through the same projection. They already came from engine reads that strip the `internal` column, so the projection holds whichever engine is wired. The audit ledger and engine write responses already omit `internal` fields, so this PR does not change them. 2. **The stored form is the platform's slow password hash.** New module `share-link-password.ts`: scrypt with the parameters account passwords use (N=16384, r=16, p=1, 64-byte key, 16-byte salt as hex, NFKC). It is built on `node:crypto` with no new dependency, and new rows are stored as `scrypt$SALT$KEY`. The two legacy forms (`sha256$…` and the `weak$…` no-SubtleCrypto fallback) still verify. `resolveToken` re-hashes a legacy row into the current form after a successful verification, but only once every later gate (standing policy, record existence, eligibility) has passed. A switched-off or ineligible link therefore takes no write. Every comparison uses `timingSafeEqual`, and the plaintext legacy form is compared through a digest of both sides. A refused upgrade write does not block the read, because the legacy form still verifies. It is reported once per instance at `error`, naming the link only. A deployment that injects its own `hashPassword`/`verifyPassword` pair is left alone. The old default could also write a `weak$` row on a runtime without SubtleCrypto; it no longer can. 3. **The password travels in a header.** One helper, `presentedPassword`, now reads the password for both public routes. `/:token/resolve` already accepted `x-share-password`. `/:token/messages` on this mount read only `?password=` and now accepts the header too, as its runtime twin always has. The query parameter is still accepted for compatibility (named in the changeset). Neither form is logged on this path: the routes write no log line, the service's log lines name the link and never the presented password, and the Hono adapter's failure log records the path without the query string. ## Tests New `src/share-link-password.test.ts`, 19 cases on a real `ObjectQL` + `driver-sql` + better-sqlite3, so the engine's strip is live: - No exit carries the `password_hash` key or any piece of the stored hash: mint (service and `POST` route), list (service and route), redemption (service and route). - Stored form matches `scrypt$` + 32 hex + `$` + 128 hex, holds no plaintext, and is salted per row. The verifier refuses wrong, empty and unknown-form inputs. - For both legacy forms (salted SHA-256, plaintext): still verifies; a wrong or missing password is refused and writes nothing; the right password upgrades the row to `scrypt$`; the upgraded hash verifies the same password and still refuses a wrong one. - No upgrade on a switched-off object. No upgrade with an injected hasher pair. A refused upgrade still serves the read, leaves the legacy form in place, is reported exactly once with `{ link, reason }`, and no log carries the password or the hash. - The list and the redemption result carry no hash even from an engine that does not strip it (a control asserts that engine does return it), so the service's own projection is pinned. - The header is accepted on `/resolve` and `/messages`, and so is `?password=`. A wrong password is refused for both forms on both routes, with ADR-0112 `code` + `success: false` asserted. No log line carries the presented password on any outcome. Local runs, final head `eacae6b6be` unless noted (`eacae6b6be` adds the non-stripping-engine pin to the test file): - `pnpm --filter @objectstack/plugin-sharing test` at head `eacae6b6be`: 39 files / 973 tests passed. - `pnpm --filter @objectstack/plugin-sharing typecheck` (src + scripts + `check:test-typecheck`): green; the test layer holds the existing 2 files / 3 pinned signatures, with nothing new. - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/share-links` (the dispatcher twin's suites, against the rebuilt plugin `dist/`): 2 files / 29 tests passed. - Lint narrowed to the 4 changed `.ts` files: `eslint --no-inline-config --format json` reports 4 files, 0 errors, 0 warnings. The population is read from `eslint.config.mjs`: `**/*.{ts,…}` covers all four. Invariance: the config uses no typed linting (no `parserOptions.project`), so this diff cannot move any verdict on an untouched file. - `dispatch-gates --ran`: 95 of 97 derived families run, all exit 0. Two are **NOT MEASURED**, both `PREREQUISITE NOT MET` (exit 3): `check:dual-build-cjs-loads` needs a whole-repo build, and `check:i18n` needs the CLI build closure. This diff changes no object, label or translation source. Both are declared to CI. Ablations (each mutation made with `scripts/ablation-replace.mjs`, which confirmed the change on disk; restored to the HEAD blob each time, confirmed by matching hashes and an empty `git diff HEAD`): - the `withoutPasswordHash` projection removed: 2 failed / 17 passed (the mint pin and the non-stripping-engine pin). - legacy upgrade disabled: 3 failed / 15 passed (both legacy-row pins and the refused-upgrade pin). - header read removed from `presentedPassword`: 4 failed / 14 passed (both header pins, the header wrong-password pin, the redemption-route pin). ## Acceptance notes - **Twin precedence.** When both forms arrive, the query parameter is read first, on both mounts: the runtime dispatcher twin (`packages/runtime/src/domains/share-links.ts`, outside this card's file surface) reads it that way, and two mounts of the same routes must not answer differently. The header is the form clients should send. Making the header win on both mounts is a small follow-up for whoever retires the query form. Carrier: the objectui console card, whose landing is the point the query parameter can go. - **Cost per protected redemption.** Verifying a protected link's password now costs one slow-hash evaluation per request, by design and at the same cost as a sign-in. - **The spec contract type is unchanged.** `ShareLink.password_hash` stays declared (optional) in `packages/spec/src/contracts/share-link-service.ts`, as the persisted-shape mirror. Only the runtime value leaving the service drops it, so no published type narrows. See `Clause-②` above. - **Census.** The upgrade write is one more engine write call site, so `node scripts/tenant-audit-census.mjs --write` regenerated `content/docs/permissions/tenant-audit-census.mdx` and its counts file. The page's hand-written figures moved from 232 to 233 (decidable 154 to 155, elevated 113 to 114). `check:tenant-audit-census` and its self-test are green. --- _Generated by [Claude Code](https://claude.ai/code/session_018zT8d8NpiQ1ExhuNd5TxY6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 41a1135 commit f5b8e29

15 files changed

Lines changed: 1232 additions & 90 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-sharing': patch
3+
'@objectstack/plugin-hono-server': patch
4+
'@objectstack/hono': patch
5+
'@objectstack/runtime': patch
6+
---
7+
8+
Share-link passwords follow the platform's credential rules (#21839).
9+
10+
- **The stored hash never leaves the server.** The share-link mint response (`POST /api/v1/share-links`, and `ShareLinkService.createLink`'s return value) no longer carries `password_hash`. The list and the redemption result are projected the same way. A client that reads a link's password state keeps reading it from the redemption route's `NEEDS_PASSWORD` answer, as before.
11+
- **The stored form is the platform's slow password hash.** New passwords are hashed with scrypt at the parameters account passwords use, instead of one salted SHA-256. Links minted before this release keep working: a stored password in a legacy form still verifies, and it is re-hashed into the new form on its first successful redemption. Every comparison is constant-time. A deployment that injects its own `hashPassword` / `verifyPassword` pair is unaffected, and its stored forms are left alone.
12+
- **The password travels in a header.** Both public share-link routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) accept the `x-share-password` request header, the preferred form, because a header is not part of the request URL. The `?password=` query parameter is still accepted for compatibility, so current consoles keep working until they move to the header. `/messages` accepted only the query parameter on this mount before.
13+
- **Cross-origin clients can send the header.** `X-Share-Password` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` is unchanged; add the header to that list to let a cross-origin client use it.
14+
- **Public share-link answers are not cached.** Both public routes answer with `Cache-Control: no-store` and `Vary: X-Share-Password` on every outcome, on both mounts (the sharing plugin's routes and the runtime dispatcher's `/share-links` domain). The authenticated create, list and revoke routes are unchanged.
15+
- **Hashing works in WebContainer.** On StackBlitz WebContainer, where `node:crypto.scrypt` is incomplete, the password is hashed with the pure-JS scrypt from `@noble/hashes` (now a dependency of `@objectstack/plugin-sharing`, as it already is of `@objectstack/plugin-auth`), at the same parameters and in the same stored form. A hash made on either runtime verifies on the other.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.
122122

123123
The same holds twice over for the context. An options argument spelled as a
124124
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
125-
forwarding shim cannot, and **67 of the 232 sites are spelled that way**. A
125+
forwarding shim cannot, and **67 of the 233 sites are spelled that way**. A
126126
context resolved from an inline literal or a local `const` can be tested for
127127
`isSystem`; one arriving from a helper call cannot.
128128

@@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
187187

188188
| carried figure | where it survives | this census |
189189
| :--- | :--- | ---: |
190-
| 175 write call sites | quoted in the merged changeset | **232** |
190+
| 175 write call sites | quoted in the merged changeset | **233** |
191191
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **34** more whose options argument is unreadable |
192-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 232** decidable, **78** undecidable |
193-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 113 decidably elevated, 0 decidably not, 102 undecidable |
192+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 233** decidable, **78** undecidable |
193+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 114 decidably elevated, 0 decidably not, 102 undecidable |
194194
| 141 and 132, two independent re-derivations | the card that filed this work | — |
195195

196196
**The differences are not reconciled, and deliberately so.** The old census's
@@ -207,11 +207,11 @@ would report a smaller number and would not say so.
207207

208208
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
209209
figure has no surviving corroboration anywhere in the tree.** This census reads
210-
113 of 232 (49%) as decidably elevated, with 102 more whose elevation is a
210+
114 of 233 (49%) as decidably elevated, with 102 more whose elevation is a
211211
run-time fact — so the claim is neither confirmed nor refuted, and the honest
212212
answer is that a static reading cannot settle it.
213213

214-
⇒ **Cite `9 / 232`, and say what it is**: the sites whose options argument was
214+
⇒ **Cite `9 / 233`, and say what it is**: the sites whose options argument was
215215
READ and holds no tenant context, against a decidably tenancy-enabled object.
216216
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
217217
without tenant context" — **34 further sites** have an options argument this
@@ -223,28 +223,28 @@ cannot read, and they are neither in nor out.
223223

224224
| what | count |
225225
| :--- | ---: |
226-
| write call sites on the application surface | **232** |
227-
| …whose object name is statically decidable | 154 |
226+
| write call sites on the application surface | **233** |
227+
| …whose object name is statically decidable | 155 |
228228
| …whose object name is chosen at run time | 78 |
229-
| …against an object with tenancy ENABLED | 153 |
229+
| …against an object with tenancy ENABLED | 154 |
230230
| …against an object that declares tenancy off | 1 |
231-
| threading a tenant context | 148 |
231+
| threading a tenant context | 149 |
232232
| PROVABLY carrying none (options read, no context key) | **17** |
233233
| …of those, against a decidably tenancy-enabled object | **9** |
234234
| options argument UNREADABLE — may or may not carry one | 67 |
235235
| …of those, against a decidably tenancy-enabled object | 34 |
236-
| threading a decidably ELEVATED (`isSystem`) context | 113 |
236+
| threading a decidably ELEVATED (`isSystem`) context | 114 |
237237
| threading a context that is decidably NOT elevated | 0 |
238238
| threading a context whose elevation is a run-time fact | 102 |
239239

240240
| how the instrument reached the site | count |
241241
| :--- | ---: |
242-
| receiver carried a readable engine type | 184 |
242+
| receiver carried a readable engine type | 185 |
243243
| receiver erased, placed by the object NAME | 28 |
244244
| receiver erased, placed by an `object: string` PARAMETER | 15 |
245245
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
246246

247-
| object name spelled inline | 103 |
247+
| object name spelled inline | 104 |
248248
| object name spelled through a `const` | 51 |
249249
| object name is an `object: string` parameter | 17 |
250250
| object name is some other run-time expression | 61 |
@@ -297,13 +297,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
297297
their values are not compared. The reasoning, and the measurement behind it,
298298
are in `scripts/check-tenant-audit-census.mjs`.
299299

300-
Measured on 2026-10-05 at `34782539c`.
300+
Measured on 2026-10-05 at `3d34c6efd`.
301301

302302
| corpus scale (not enforced) | count |
303303
| :--- | ---: |
304-
| tracked non-test sources scanned | 605 |
305-
| engine-shaped types recognised | 68 |
304+
| tracked non-test sources scanned | 607 |
305+
| engine-shaped types recognised | 69 |
306306
| declared objects in the registry | 116 |
307-
| same-named calls subtracted as non-engine | 155 |
307+
| same-named calls subtracted as non-engine | 158 |
308308

309309
{/* END GENERATED: tenant-audit-census */}

‎content/docs/protocol/kernel/http-protocol.mdx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -999,18 +999,19 @@ ObjectStack sends CORS headers automatically:
999999
```http
10001000
Access-Control-Allow-Origin: https://app.acme.com
10011001
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
1002-
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match
1002+
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match, X-Share-Password
10031003
Access-Control-Expose-Headers: set-auth-token, x-objectstack-dropped-fields
10041004
Access-Control-Max-Age: 86400
10051005
```
10061006

1007-
Three of the allowed request headers are easy to overlook, and each one disables
1007+
Four of the allowed request headers are easy to overlook, and each one disables
10081008
a feature if an intermediate proxy strips it:
10091009

10101010
| Header | Why it is allowed |
10111011
| --- | --- |
10121012
| `X-Tenant-ID` / `X-Environment-Id` | Route the request to its environment on a multi-tenant host. |
10131013
| `If-Match` | Carries the OCC token on record `PATCH`es. Without it, a cross-origin save fails in the browser with "Failed to fetch". |
1014+
| `X-Share-Password` | Carries a share-link password to the public `/share-links/:token/resolve` and `/messages` routes. It is the preferred form because a header stays out of URLs; without it, a cross-origin client can only send the password as a query parameter. |
10141015

10151016
The two **exposed** response headers matter to browser clients specifically:
10161017
`set-auth-token` delivers a rotated session token (without it a cross-origin

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 232 |
37-
| Object name statically decidable | 154 |
36+
| Write call sites | 233 |
37+
| Object name statically decidable | 155 |
3838
| Object name chosen at run time | 78 |
39-
| Against a tenancy-enabled object | 153 |
39+
| Against a tenancy-enabled object | 154 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 148 |
41+
| Threading a tenant context | 149 |
4242
| Provably carrying none | 17 |
4343
| …and decidably tenancy-enabled | 9 |
4444
| Options argument unreadable | 67 |
4545
| …and decidably tenancy-enabled | 34 |
46-
| Threading a decidably elevated context | 113 |
46+
| Threading a decidably elevated context | 114 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 102 |
4949

@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-05 at `34782539c`.
93+
Measured on 2026-10-05 at `3d34c6efd`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 605 |
98-
| engine-shaped types recognised | 68 |
97+
| tracked non-test sources scanned | 607 |
98+
| engine-shaped types recognised | 69 |
9999
| declared objects in the registry | 116 |
100-
| same-named calls subtracted as non-engine | 155 |
100+
| same-named calls subtracted as non-engine | 158 |
101101

102102
## Every site
103103

@@ -183,7 +183,7 @@ Measured on 2026-10-05 at `34782539c`.
183183
| `packages/plugins/plugin-sharing/src/primary-bu-projection.ts` | `update` | `sys_user` | enabled | elevated | 2 |
184184
| `packages/plugins/plugin-sharing/src/record-orphan-cleanup.ts` | `delete` | `table` | undecidable | options unreadable | 2 |
185185
| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `insert` | `sys_share_link` | enabled | elevated | 1 |
186-
| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `update` | `sys_share_link` | enabled | elevated | 2 |
186+
| `packages/plugins/plugin-sharing/src/share-link-service.ts` | `update` | `sys_share_link` | enabled | elevated | 3 |
187187
| `packages/plugins/plugin-sharing/src/sharing-plugin.ts` | `update` | `object` | undecidable | elevated | 1 |
188188
| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `delete` | `sys_record_share` | enabled | options unreadable | 3 |
189189
| `packages/plugins/plugin-sharing/src/sharing-rule-service.ts` | `delete` | `sys_sharing_rule` | enabled | options unreadable | 1 |

‎packages/plugins/plugin-hono-server/src/adapter.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ import {
6464
* `If-Match` carries the OCC token on record PATCHes (objectui's inline edit,
6565
* REST `update` with `ifMatch`) — without it in the preflight allow-list every
6666
* cross-origin save fails in the browser with "Failed to fetch" (objectui#2572).
67+
* `X-Share-Password` carries a share-link password to the public
68+
* `/share-links/:token/resolve` and `/messages` routes — the preferred form,
69+
* since a header stays out of URLs (#21839); without it here a cross-origin
70+
* client could only use the query-parameter form.
6771
*/
6872
export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([
6973
'Content-Type',
@@ -72,6 +76,7 @@ export const DEFAULT_CORS_ALLOW_HEADERS: readonly string[] = Object.freeze([
7276
'X-Tenant-ID',
7377
'X-Environment-Id',
7478
'If-Match',
79+
'X-Share-Password',
7580
]);
7681

7782
/**

‎packages/plugins/plugin-hono-server/src/hono-plugin.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,17 @@ describe('HonoServerPlugin', () => {
313313
expect(corsConfigCapture.last.allowHeaders).toContain('If-Match');
314314
});
315315

316+
it('should allow X-Share-Password by default (share-link password header, #21839)', async () => {
317+
corsConfigCapture.last = undefined;
318+
319+
const plugin = new HonoServerPlugin();
320+
await plugin.init(context as PluginContext);
321+
322+
// The header form keeps the password out of URLs; a preflight that
323+
// does not allow it leaves a cross-origin client only the query form.
324+
expect(corsConfigCapture.last.allowHeaders).toContain('X-Share-Password');
325+
});
326+
316327
it('should merge user-supplied exposeHeaders with set-auth-token default', async () => {
317328
corsConfigCapture.last = undefined;
318329

‎packages/plugins/plugin-sharing/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"gen:test-typecheck-debt": "tsx ../../../scripts/check-test-typecheck.mts --update --package packages/plugins/plugin-sharing --project tsconfig.test.json"
2121
},
2222
"dependencies": {
23+
"@noble/hashes": "^2.4.0",
2324
"@objectstack/core": "workspace:*",
2425
"@objectstack/formula": "workspace:*",
2526
"@objectstack/metadata-core": "workspace:*",
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21839] On WebContainer the share-link password is hashed by the pure-JS
5+
* scrypt (`@noble/hashes`), because that host's `node:crypto.scrypt` is
6+
* incomplete; everywhere else by `node:crypto`. The two must be one hash.
7+
*
8+
* Pinned:
9+
* - with a WebContainer signal set, `node:crypto.scrypt` is never called —
10+
* the pure-JS path really ran;
11+
* - without one, it is — the native path really ran;
12+
* - a hash minted on either path verifies on the other, and a wrong password
13+
* is refused on both;
14+
* - the two paths derive byte-identical keys for the same password and salt
15+
* (compared directly against `node:crypto`).
16+
*/
17+
18+
import { describe, it, expect, vi, afterEach } from 'vitest';
19+
import * as nodeCrypto from 'node:crypto';
20+
import { hashShareLinkPassword, verifyShareLinkPassword } from './share-link-password.js';
21+
22+
vi.mock('node:crypto', async (importOriginal) => {
23+
const actual = await importOriginal<typeof import('node:crypto')>();
24+
return { ...actual, scrypt: vi.fn(actual.scrypt) };
25+
});
26+
27+
const PASSWORD = 'Ünïcödé pass 21839';
28+
const nodeScrypt = vi.mocked(nodeCrypto.scrypt);
29+
30+
function onWebContainer(on: boolean) {
31+
vi.stubEnv('STACKBLITZ', on ? '1' : '');
32+
vi.stubEnv('SHELL', on ? '/bin/jsh' : '/bin/bash');
33+
}
34+
35+
afterEach(() => {
36+
vi.unstubAllEnvs();
37+
nodeScrypt.mockClear();
38+
});
39+
40+
describe('[#21839] share-link password: node:crypto and pure-JS scrypt are interchangeable', () => {
41+
it('the WebContainer path does not touch node:crypto.scrypt; the native path does', async () => {
42+
onWebContainer(true);
43+
await hashShareLinkPassword(PASSWORD);
44+
expect(nodeScrypt).not.toHaveBeenCalled();
45+
46+
onWebContainer(false);
47+
await hashShareLinkPassword(PASSWORD);
48+
expect(nodeScrypt).toHaveBeenCalledTimes(1);
49+
});
50+
51+
it('a hash minted on WebContainer verifies natively, and the reverse', async () => {
52+
onWebContainer(true);
53+
const pureJsHash = await hashShareLinkPassword(PASSWORD);
54+
onWebContainer(false);
55+
const nativeHash = await hashShareLinkPassword(PASSWORD);
56+
57+
expect(pureJsHash).toMatch(/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/);
58+
expect(nativeHash).toMatch(/^scrypt\$[0-9a-f]{32}\$[0-9a-f]{128}$/);
59+
60+
onWebContainer(false);
61+
expect(await verifyShareLinkPassword(PASSWORD, pureJsHash)).toBe(true);
62+
expect(await verifyShareLinkPassword('wrong 21839', pureJsHash)).toBe(false);
63+
expect(nodeScrypt).toHaveBeenCalled();
64+
65+
onWebContainer(true);
66+
nodeScrypt.mockClear();
67+
expect(await verifyShareLinkPassword(PASSWORD, nativeHash)).toBe(true);
68+
expect(await verifyShareLinkPassword('wrong 21839', nativeHash)).toBe(false);
69+
expect(nodeScrypt).not.toHaveBeenCalled();
70+
});
71+
72+
it('a password whose NFKC form differs from its input is one password on both paths', async () => {
73+
// A decomposed e + combining acute, and a fullwidth A: NFKC rewrites both.
74+
const raw = 'cafe\u0301 \uFF21 21839';
75+
const normalised = 'caf\u00e9 A 21839';
76+
expect(raw).not.toBe(normalised);
77+
expect(raw.normalize('NFKC')).toBe(normalised);
78+
79+
// pure-JS mints, native verifies — either spelling of the same password.
80+
onWebContainer(true);
81+
const pureJsHash = await hashShareLinkPassword(raw);
82+
onWebContainer(false);
83+
expect(await verifyShareLinkPassword(raw, pureJsHash)).toBe(true);
84+
expect(await verifyShareLinkPassword(normalised, pureJsHash)).toBe(true);
85+
expect(await verifyShareLinkPassword('cafe A 21839', pureJsHash)).toBe(false);
86+
87+
// native mints, pure-JS verifies — either spelling of the same password.
88+
onWebContainer(false);
89+
const nativeHash = await hashShareLinkPassword(raw);
90+
onWebContainer(true);
91+
nodeScrypt.mockClear();
92+
expect(await verifyShareLinkPassword(raw, nativeHash)).toBe(true);
93+
expect(await verifyShareLinkPassword(normalised, nativeHash)).toBe(true);
94+
expect(await verifyShareLinkPassword('cafe A 21839', nativeHash)).toBe(false);
95+
expect(nodeScrypt).not.toHaveBeenCalled();
96+
});
97+
98+
it('the pure-JS key is byte-identical to node:crypto for the same password and salt', async () => {
99+
onWebContainer(true);
100+
const hash = await hashShareLinkPassword(PASSWORD);
101+
const [, saltHex, keyHex] = hash.split('$');
102+
const expected = nodeCrypto
103+
.scryptSync(PASSWORD.normalize('NFKC'), saltHex!, 64, { N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2 })
104+
.toString('hex');
105+
expect(keyHex).toBe(expected);
106+
});
107+
});

0 commit comments

Comments
 (0)