@prosopo/procaptcha-puzzle 2.10.8 → 2.10.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,454 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { stringToHex } from "@polkadot/util/string";
16
+ import { ProviderApi } from "@prosopo/api";
17
+ import { ProsopoEnvError } from "@prosopo/common";
18
+ import {
19
+ ExtensionLoader,
20
+ buildUpdateState,
21
+ getProcaptchaRandomActiveProvider,
22
+ providerRetry,
23
+ } from "@prosopo/procaptcha-common";
24
+ import { getDefaultEvents } from "@prosopo/procaptcha-common";
25
+ import {
26
+ type Account,
27
+ ApiParams,
28
+ type FrictionlessState,
29
+ type GetPuzzleCaptchaResponse,
30
+ type ProcaptchaCallbacks,
31
+ type ProcaptchaClientConfigInput,
32
+ ProcaptchaConfigSchema,
33
+ type ProcaptchaState,
34
+ type ProcaptchaStateUpdateFn,
35
+ type PuzzleEvent,
36
+ encodeProcaptchaOutput,
37
+ } from "@prosopo/types";
38
+ import { embedData, sleep } from "@prosopo/util";
39
+ import { randomAsHex } from "@prosopo/util-crypto";
40
+
41
+ interface PuzzleManagerHandle {
42
+ start: (
43
+ x?: number,
44
+ y?: number,
45
+ ) => Promise<GetPuzzleCaptchaResponse | undefined>;
46
+ submitSolution: (
47
+ finalX: number,
48
+ finalY: number,
49
+ puzzleEvents: PuzzleEvent[],
50
+ ) => Promise<boolean>;
51
+ resetState: (frictionlessRestart?: () => void) => void;
52
+ }
53
+
54
+ export const Manager = (
55
+ configInput: ProcaptchaClientConfigInput,
56
+ state: ProcaptchaState,
57
+ onStateUpdate: ProcaptchaStateUpdateFn,
58
+ callbacks: ProcaptchaCallbacks,
59
+ frictionlessState?: FrictionlessState,
60
+ // Reads the live honeypot input value at submit time. Returns undefined
61
+ // when the honeypot is disabled or the input hasn't been filled.
62
+ getHoneypotValue?: () => string | undefined,
63
+ ): PuzzleManagerHandle => {
64
+ const events = getDefaultEvents(callbacks);
65
+
66
+ // Closure variables to share state between start and submitSolution
67
+ let storedChallengeResponse: GetPuzzleCaptchaResponse | undefined;
68
+ let storedProviderApi: ProviderApi | undefined;
69
+ let storedProviderUrl: string | undefined;
70
+ let storedUser: Account | undefined;
71
+ // Checkbox click coords, captured by start() and embedded into the
72
+ // solution salt at submit time — same shape as the POW flow so the
73
+ // provider records identical entry-point telemetry for both types.
74
+ let storedClickX: number | undefined;
75
+ let storedClickY: number | undefined;
76
+
77
+ const defaultState = (): Partial<ProcaptchaState> => {
78
+ return {
79
+ // note order matters! see buildUpdateState. These fields are set in order, so disable modal first, then set loading to false, etc.
80
+ showModal: false,
81
+ loading: false,
82
+ index: 0,
83
+ challenge: undefined,
84
+ solutions: undefined,
85
+ isHuman: false,
86
+ captchaApi: undefined,
87
+ account: undefined,
88
+ // don't handle timeout here, this should be handled by the state management
89
+ };
90
+ };
91
+
92
+ const clearTimeout = () => {
93
+ // clear the timeout
94
+ window.clearTimeout(Number(state.timeout));
95
+ // then clear the timeout from the state
96
+ updateState({ timeout: undefined });
97
+ };
98
+
99
+ const onFailed = () => {
100
+ updateState({
101
+ isHuman: false,
102
+ loading: false,
103
+ });
104
+ events.onFailed();
105
+ resetState(frictionlessState?.restart);
106
+ };
107
+
108
+ const clearSuccessfulChallengeTimeout = () => {
109
+ // clear the timeout
110
+ window.clearTimeout(Number(state.successfullChallengeTimeout));
111
+ // then clear the timeout from the state
112
+ updateState({ successfullChallengeTimeout: undefined });
113
+ };
114
+
115
+ const getConfig = () => {
116
+ const config: ProcaptchaClientConfigInput = {
117
+ userAccountAddress: configInput.userAccountAddress || "",
118
+ ...configInput,
119
+ };
120
+
121
+ // overwrite the account in use with the one in state if it exists. Reduces likelihood of bugs where the user
122
+ // changes account in the middle of the captcha process.
123
+ if (state.account) {
124
+ config.userAccountAddress = state.account.account.address;
125
+ }
126
+
127
+ return ProcaptchaConfigSchema.parse(config);
128
+ };
129
+
130
+ const getAccount = () => {
131
+ if (!state.account) {
132
+ throw new ProsopoEnvError("GENERAL.ACCOUNT_NOT_FOUND", {
133
+ context: { error: "Account not loaded" },
134
+ });
135
+ }
136
+ const account: Account = state.account;
137
+ return { account };
138
+ };
139
+
140
+ const getDappAccount = () => {
141
+ if (!state.dappAccount) {
142
+ throw new ProsopoEnvError("GENERAL.SITE_KEY_MISSING");
143
+ }
144
+
145
+ const dappAccount: string = state.dappAccount;
146
+ return dappAccount;
147
+ };
148
+
149
+ // get the state update mechanism
150
+ const updateState = buildUpdateState(state, onStateUpdate);
151
+
152
+ const resetState = (frictionlessRestart?: () => void) => {
153
+ // clear timeout just in case a timer is still active (shouldn't be)
154
+ clearTimeout();
155
+ clearSuccessfulChallengeTimeout();
156
+ updateState(defaultState());
157
+ events.onReset();
158
+ // reset the frictionless state if necessary
159
+ if (frictionlessRestart) {
160
+ frictionlessRestart();
161
+ }
162
+ // clear closure state
163
+ storedChallengeResponse = undefined;
164
+ storedProviderApi = undefined;
165
+ storedProviderUrl = undefined;
166
+ storedUser = undefined;
167
+ storedClickX = undefined;
168
+ storedClickY = undefined;
169
+ };
170
+
171
+ const setValidChallengeTimeout = () => {
172
+ const timeMillis: number = getConfig().captchas.puzzle.solutionTimeout;
173
+ const successfullChallengeTimeout = setTimeout(() => {
174
+ // Human state expired, disallow user's claim to be human
175
+ updateState({ isHuman: false });
176
+
177
+ events.onExpired();
178
+ resetState(frictionlessState?.restart);
179
+ }, timeMillis);
180
+
181
+ updateState({ successfullChallengeTimeout });
182
+ };
183
+
184
+ const start = async (
185
+ x = 0,
186
+ y = 0,
187
+ ): Promise<GetPuzzleCaptchaResponse | undefined> => {
188
+ // Persist click coords on every entry so retries inherit the
189
+ // trusted coordinates captured by the widget on initial click.
190
+ storedClickX = x;
191
+ storedClickY = y;
192
+
193
+ await providerRetry(
194
+ async () => {
195
+ if (state.loading) {
196
+ return;
197
+ }
198
+ if (state.isHuman) {
199
+ return;
200
+ }
201
+
202
+ // reset the state to defaults - do not reset the frictionless state
203
+ resetState();
204
+
205
+ // set the loading flag to true (allow UI to show some sort of loading / pending indicator while we get the captcha process going)
206
+ updateState({
207
+ loading: true,
208
+ });
209
+ updateState({ attemptCount: state.attemptCount + 1 });
210
+
211
+ const config = getConfig();
212
+
213
+ // check if account exists in extension
214
+ const selectAccount = async () => {
215
+ if (frictionlessState) {
216
+ return frictionlessState.userAccount;
217
+ }
218
+ const ext = new (await ExtensionLoader(config.web2))();
219
+ return ext.getAccount(config);
220
+ };
221
+
222
+ // use the passed in account (could be web3) or create a new account
223
+ const user = await selectAccount();
224
+ const userAccount = user.account.address;
225
+
226
+ // set the account created or injected by the extension
227
+ updateState({
228
+ account: { account: { address: userAccount } },
229
+ });
230
+
231
+ // snapshot the config into the state
232
+ updateState({ dappAccount: config.account.address });
233
+
234
+ // allow UI to catch up with the loading state
235
+ await sleep(100);
236
+
237
+ // check if account has been provided in config (doesn't matter in web2 mode)
238
+ if (!config.web2 && !config.userAccountAddress) {
239
+ throw new ProsopoEnvError("GENERAL.ACCOUNT_NOT_FOUND", {
240
+ context: {
241
+ error: "Account address has not been set for web3 mode",
242
+ },
243
+ });
244
+ }
245
+
246
+ let getRandomProviderResponse = undefined;
247
+
248
+ if (frictionlessState?.provider) {
249
+ getRandomProviderResponse = frictionlessState.provider;
250
+ } else {
251
+ getRandomProviderResponse = await getProcaptchaRandomActiveProvider(
252
+ getConfig().defaultEnvironment,
253
+ );
254
+ }
255
+
256
+ const providerUrl = getRandomProviderResponse.provider.url;
257
+
258
+ const providerApi = new ProviderApi(providerUrl, getDappAccount());
259
+
260
+ // Non-blocking check — attach SIMD readings only if the
261
+ // prefetched benchmark has already resolved.
262
+ const simdReadingsOnChallenge = frictionlessState?.getSimdReadings
263
+ ? await frictionlessState.getSimdReadings(0)
264
+ : undefined;
265
+ const challenge = await providerApi.getPuzzleCaptchaChallenge(
266
+ userAccount,
267
+ getDappAccount(),
268
+ frictionlessState?.sessionId,
269
+ simdReadingsOnChallenge,
270
+ );
271
+
272
+ if (challenge.error) {
273
+ updateState({
274
+ loading: false,
275
+ error: {
276
+ message: challenge.error.message,
277
+ key: challenge.error.key || "API.UNKNOWN_ERROR",
278
+ },
279
+ });
280
+ return;
281
+ }
282
+
283
+ // Store closure state for submitSolution
284
+ storedChallengeResponse = challenge;
285
+ storedProviderApi = providerApi;
286
+ storedProviderUrl = providerUrl;
287
+ storedUser = user;
288
+
289
+ // Set loading to false to signal the widget to show the puzzle canvas
290
+ updateState({
291
+ loading: false,
292
+ });
293
+ },
294
+ async () => {
295
+ await start();
296
+ },
297
+ () => {
298
+ resetState();
299
+ },
300
+ state.attemptCount,
301
+ 3,
302
+ );
303
+
304
+ // Return the stored challenge so retries (which re-enter `start`)
305
+ // still surface the resolved challenge to the original caller.
306
+ return storedChallengeResponse;
307
+ };
308
+
309
+ const submitSolution = async (
310
+ finalX: number,
311
+ finalY: number,
312
+ puzzleEvents: PuzzleEvent[],
313
+ ): Promise<boolean> => {
314
+ if (
315
+ !storedChallengeResponse ||
316
+ !storedProviderApi ||
317
+ !storedProviderUrl ||
318
+ !storedUser
319
+ ) {
320
+ throw new ProsopoEnvError("GENERAL.ACCOUNT_NOT_FOUND", {
321
+ context: { error: "No challenge data available. Call start() first." },
322
+ });
323
+ }
324
+
325
+ updateState({ loading: true });
326
+
327
+ try {
328
+ const challenge = storedChallengeResponse;
329
+ const providerApi = storedProviderApi;
330
+ const providerUrl = storedProviderUrl;
331
+ const user = storedUser;
332
+ const config = getConfig();
333
+
334
+ const signer = user.extension?.signer;
335
+
336
+ if (!signer || !signer.signRaw) {
337
+ throw new ProsopoEnvError("GENERAL.CANT_FIND_KEYRINGPAIR", {
338
+ context: {
339
+ error:
340
+ "Signer is not defined, cannot sign message to prove account ownership",
341
+ },
342
+ });
343
+ }
344
+
345
+ const userTimestampSignature = await signer.signRaw({
346
+ address: user.account.address,
347
+ data: stringToHex(challenge[ApiParams.timestamp].toString()),
348
+ type: "bytes",
349
+ });
350
+
351
+ let encryptedBehavioralData: string | undefined;
352
+
353
+ // Collect and encrypt behavioral data before submission
354
+ if (
355
+ frictionlessState?.encryptBehavioralData &&
356
+ (frictionlessState?.behaviorCollector1 ||
357
+ frictionlessState?.behaviorCollector2 ||
358
+ frictionlessState?.behaviorCollector3)
359
+ ) {
360
+ try {
361
+ const behavioralData = {
362
+ collector1: frictionlessState.behaviorCollector1?.getData() || [],
363
+ collector2: frictionlessState.behaviorCollector2?.getData() || [],
364
+ collector3: frictionlessState.behaviorCollector3?.getData() || [],
365
+ deviceCapability: frictionlessState.deviceCapability || "unknown",
366
+ };
367
+
368
+ // Pack the behavioral data before stringifying
369
+ const dataToEncrypt = frictionlessState.packBehavioralData
370
+ ? frictionlessState.packBehavioralData(behavioralData)
371
+ : behavioralData;
372
+
373
+ encryptedBehavioralData =
374
+ await frictionlessState.encryptBehavioralData(
375
+ JSON.stringify(dataToEncrypt),
376
+ );
377
+ } catch {
378
+ // Silently ignore behavioral data errors - captcha should still work
379
+ }
380
+ }
381
+
382
+ // Encode the checkbox click coordinates into a random salt, same
383
+ // shape as the POW flow. The provider decodes this on submit and
384
+ // records the (x, y) on the puzzle captcha record for telemetry.
385
+ let salt: string | undefined;
386
+ if (storedClickX !== undefined && storedClickY !== undefined) {
387
+ const coords = [storedClickX, storedClickY];
388
+ const randomSalt = randomAsHex(
389
+ coords
390
+ .map((coord) => coord.toString(16).length + 4)
391
+ .reduce((acc, curr) => acc + curr, 0),
392
+ );
393
+ salt = embedData(randomSalt, coords);
394
+ }
395
+
396
+ const simdReadings = frictionlessState?.getSimdReadings
397
+ ? await frictionlessState.getSimdReadings()
398
+ : undefined;
399
+ const hpValue = getHoneypotValue?.();
400
+ const clientMetaData = hpValue ? { hp: hpValue } : undefined;
401
+ const verifiedSolution = await providerApi.submitPuzzleCaptchaSolution(
402
+ challenge,
403
+ getAccount().account.account.address,
404
+ getDappAccount(),
405
+ finalX,
406
+ finalY,
407
+ puzzleEvents,
408
+ userTimestampSignature.signature.toString(),
409
+ config.captchas.puzzle.verifiedTimeout,
410
+ encryptedBehavioralData,
411
+ salt,
412
+ simdReadings,
413
+ clientMetaData,
414
+ );
415
+
416
+ if (verifiedSolution[ApiParams.verified]) {
417
+ updateState({
418
+ isHuman: true,
419
+ loading: false,
420
+ });
421
+
422
+ events.onHuman(
423
+ encodeProcaptchaOutput({
424
+ [ApiParams.providerUrl]: providerUrl,
425
+ [ApiParams.user]: getAccount().account.account.address,
426
+ [ApiParams.dapp]: getDappAccount(),
427
+ [ApiParams.challenge]: challenge.challenge,
428
+ [ApiParams.timestamp]: challenge.timestamp,
429
+ [ApiParams.signature]: {
430
+ [ApiParams.provider]: challenge.signature.provider,
431
+ [ApiParams.user]: {
432
+ [ApiParams.timestamp]:
433
+ userTimestampSignature.signature.toString(),
434
+ },
435
+ },
436
+ }),
437
+ );
438
+ setValidChallengeTimeout();
439
+ return true;
440
+ }
441
+ onFailed();
442
+ return false;
443
+ } catch (error) {
444
+ updateState({ loading: false });
445
+ throw error;
446
+ }
447
+ };
448
+
449
+ return {
450
+ start,
451
+ submitSolution,
452
+ resetState,
453
+ };
454
+ };
@@ -0,0 +1,44 @@
1
+ {
2
+ "extends": "../../tsconfig.cjs.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist/cjs",
6
+ "lib": ["es6", "dom"],
7
+ "jsxImportSource": "@emotion/react"
8
+ },
9
+ "include": [
10
+ "./src/**/*.ts",
11
+ "./src/**/*.json",
12
+ "./src/**/*.d.ts",
13
+ "./src/**/*.tsx"
14
+ ],
15
+ "references": [
16
+ {
17
+ "path": "../../dev/config/tsconfig.cjs.json"
18
+ },
19
+ {
20
+ "path": "../api/tsconfig.cjs.json"
21
+ },
22
+ {
23
+ "path": "../common/tsconfig.cjs.json"
24
+ },
25
+ {
26
+ "path": "../locale/tsconfig.cjs.json"
27
+ },
28
+ {
29
+ "path": "../procaptcha-common/tsconfig.cjs.json"
30
+ },
31
+ {
32
+ "path": "../types/tsconfig.cjs.json"
33
+ },
34
+ {
35
+ "path": "../util/tsconfig.cjs.json"
36
+ },
37
+ {
38
+ "path": "../util-crypto/tsconfig.cjs.json"
39
+ },
40
+ {
41
+ "path": "../widget-skeleton/tsconfig.cjs.json"
42
+ }
43
+ ]
44
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "extends": "../../tsconfig.esm.json",
3
+ "compilerOptions": {
4
+ "rootDir": "./src",
5
+ "outDir": "./dist",
6
+ "lib": ["es6", "dom"],
7
+ "jsxImportSource": "@emotion/react"
8
+ },
9
+ "include": [
10
+ "src",
11
+ "src/**/*.json",
12
+ "src/**/*.ts",
13
+ "src/**/*.tsx",
14
+ "src/**/*.d.ts"
15
+ ],
16
+ "references": [
17
+ {
18
+ "path": "../../dev/config/tsconfig.json"
19
+ },
20
+ {
21
+ "path": "../api"
22
+ },
23
+ {
24
+ "path": "../common"
25
+ },
26
+ {
27
+ "path": "../locale"
28
+ },
29
+ {
30
+ "path": "../procaptcha-common"
31
+ },
32
+ {
33
+ "path": "../types"
34
+ },
35
+ {
36
+ "path": "../util"
37
+ },
38
+ {
39
+ "path": "../util-crypto"
40
+ },
41
+ {
42
+ "path": "../widget-skeleton"
43
+ }
44
+ ]
45
+ }