|
57 | 57 | * probes recover it the fail-closed way), ONE `ShareLinkService` with the |
58 | 58 | * production password hasher, and the password sent the way the console's |
59 | 59 | * landing page sends it: the `X-Share-Password` header. Refusals are read as |
60 | | - * the ADR-0112 envelope's `status` + `error.code`. |
| 60 | + * the ADR-0112 envelope's `status` + `error.code`. Every password verdict is |
| 61 | + * the production verifier's own; a pair it already judged is answered from |
| 62 | + * a memo of that verdict (see `beforeAll`). |
61 | 63 | */ |
62 | 64 |
|
63 | 65 | import { describe, it, expect, beforeAll, afterAll } from 'vitest'; |
@@ -170,6 +172,35 @@ beforeAll(async () => { |
170 | 172 | await engine.insert(OBJECT, { id: RECORD, title: 'Shared doc' }, { context: { isSystem: true } } as any); |
171 | 173 |
|
172 | 174 | const svc = new ShareLinkService({ engine: engine as any }); |
| 175 | + // [#24633] One scrypt verification per distinct (password, hash) pair, not |
| 176 | + // one per request. The production verifier derives a key at the account |
| 177 | + // parameters (scrypt N=16384, r=16) on every call, and `resolveToken` calls |
| 178 | + // it for every password presented to a protected link, an `email` one |
| 179 | + // included whatever email was sent. Counted at `eadea5720`: 70 calls, plus |
| 180 | + // the 4 mints below, and they were the file's wall time. It read 34.84 s in |
| 181 | + // CI (run 39298888283, Test Core 6/6, attempt 2), and the loop case with |
| 182 | + // 10 calls timed out at vitest's 5000 ms default on attempt 1. The calls |
| 183 | + // hold only 6 distinct pairs (3 protected live links x a wrong and the |
| 184 | + // correct password), and a verdict is a pure function of its pair, so each |
| 185 | + // pair is judged once by the real verifier and its verdict replayed. |
| 186 | + // Every request is still made, so every answer is still a real request's, |
| 187 | + // and the service is still built with both password members at their |
| 188 | + // defaults, as production builds it (so its legacy-hash upgrade stays on; |
| 189 | + // no row here is legacy). The memo wraps the instance's own verifier, |
| 190 | + // which is not exported. If that member moves, this throws rather than |
| 191 | + // leave the file paying for every call again. |
| 192 | + const productionVerify: unknown = (svc as any).verifyPassword; |
| 193 | + if (typeof productionVerify !== 'function') { |
| 194 | + throw new Error('ShareLinkService no longer keeps its password verifier as `verifyPassword`; re-point the memo below'); |
| 195 | + } |
| 196 | + const verdicts = new Map<string, Map<string, Promise<boolean>>>(); |
| 197 | + (svc as any).verifyPassword = (plain: string, hash: string): Promise<boolean> => { |
| 198 | + let byPlain = verdicts.get(hash); |
| 199 | + if (!byPlain) verdicts.set(hash, (byPlain = new Map())); |
| 200 | + let verdict = byPlain.get(plain); |
| 201 | + if (!verdict) byPlain.set(plain, (verdict = (productionVerify as (p: string, h: string) => Promise<boolean>)(plain, hash))); |
| 202 | + return verdict; |
| 203 | + }; |
173 | 204 | const creator = { isSystem: true, userId: 'usr_creator' } as any; |
174 | 205 | const mint = async (audience: 'public' | 'signed_in' | 'email', password?: string) => |
175 | 206 | svc.createLink( |
|
0 commit comments