@prosopo/procaptcha-pow 2.13.7 → 2.13.8

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.
@@ -1,482 +0,0 @@
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
- FINGERPRINT_DISCLOSURE_KEYS,
20
- encodeFingerprintProof,
21
- getFingerprintProof,
22
- } from "@prosopo/fingerprint";
23
- import {
24
- ExtensionLoader,
25
- buildClientMetaData,
26
- buildUpdateState,
27
- getDefaultEvents,
28
- getProcaptchaRandomActiveProvider,
29
- getSimdReadingsForSubmit,
30
- pickIpMode,
31
- providerRetry,
32
- } from "@prosopo/procaptcha-common";
33
- import {
34
- type Account,
35
- ApiParams,
36
- CaptchaType,
37
- type FrictionlessState,
38
- type ProcaptchaCallbacks,
39
- type ProcaptchaClientConfigInput,
40
- ProcaptchaConfigSchema,
41
- type ProcaptchaEscalationHandler,
42
- type ProcaptchaState,
43
- type ProcaptchaStateUpdateFn,
44
- encodeProcaptchaOutput,
45
- } from "@prosopo/types";
46
- import { embedData, sleep } from "@prosopo/util";
47
- import { solvePoW } from "@prosopo/util";
48
- import { randomAsHex } from "@prosopo/util-crypto";
49
-
50
- export const Manager = (
51
- configInput: ProcaptchaClientConfigInput,
52
- state: ProcaptchaState,
53
- onStateUpdate: ProcaptchaStateUpdateFn,
54
- callbacks: ProcaptchaCallbacks,
55
- frictionlessState?: FrictionlessState,
56
- onEscalate?: ProcaptchaEscalationHandler,
57
- // Reads the live honeypot input value at submit time. Returns undefined
58
- // when the input doesn't exist (honeypot disabled) or hasn't been filled.
59
- // Bots that auto-fill text inputs populate it; humans never see it.
60
- getHoneypotValue?: () => string | undefined,
61
- ) => {
62
- const events = getDefaultEvents(callbacks);
63
-
64
- // URL of the provider used on the previous attempt. On a retry we exclude it
65
- // from the candidate pool so the fallback lands on a different provider.
66
- let previousProviderUrl: string | undefined;
67
-
68
- const defaultState = (): Partial<ProcaptchaState> => {
69
- return {
70
- // note order matters! see buildUpdateState. These fields are set in order, so disable modal first, then set loading to false, etc.
71
- showModal: false,
72
- loading: false,
73
- index: 0,
74
- challenge: undefined,
75
- solutions: undefined,
76
- isHuman: false,
77
- captchaApi: undefined,
78
- account: undefined,
79
- // don't handle timeout here, this should be handled by the state management
80
- };
81
- };
82
-
83
- const clearTimeout = () => {
84
- // clear the timeout
85
- window.clearTimeout(Number(state.timeout));
86
- // then clear the timeout from the state
87
- updateState({ timeout: undefined });
88
- };
89
-
90
- const onFailed = () => {
91
- updateState({
92
- isHuman: false,
93
- loading: false,
94
- });
95
- events.onFailed();
96
- resetState(frictionlessState?.restart);
97
- };
98
-
99
- const clearSuccessfulChallengeTimeout = () => {
100
- // clear the timeout
101
- window.clearTimeout(Number(state.successfullChallengeTimeout));
102
- // then clear the timeout from the state
103
- updateState({ successfullChallengeTimeout: undefined });
104
- };
105
-
106
- const getConfig = () => {
107
- const config: ProcaptchaClientConfigInput = {
108
- userAccountAddress: configInput.userAccountAddress || "",
109
- ...configInput,
110
- };
111
-
112
- // overwrite the account in use with the one in state if it exists. Reduces likelihood of bugs where the user
113
- // changes account in the middle of the captcha process.
114
- if (state.account) {
115
- config.userAccountAddress = state.account.account.address;
116
- }
117
-
118
- return ProcaptchaConfigSchema.parse(config);
119
- };
120
-
121
- const getAccount = () => {
122
- if (!state.account) {
123
- throw new ProsopoEnvError("GENERAL.ACCOUNT_NOT_FOUND", {
124
- context: { error: "Account not loaded" },
125
- });
126
- }
127
- const account: Account = state.account;
128
- return { account };
129
- };
130
-
131
- const getDappAccount = () => {
132
- if (!state.dappAccount) {
133
- throw new ProsopoEnvError("GENERAL.SITE_KEY_MISSING");
134
- }
135
-
136
- const dappAccount: string = state.dappAccount;
137
- return dappAccount;
138
- };
139
-
140
- // get the state update mechanism
141
- const updateState = buildUpdateState(state, onStateUpdate);
142
-
143
- const resetState = (frictionlessRestart?: () => void) => {
144
- // clear timeout just in case a timer is still active (shouldn't be)
145
- clearTimeout();
146
- clearSuccessfulChallengeTimeout();
147
- updateState(defaultState());
148
- events.onReset();
149
- // reset the frictionless state if necessary
150
- if (frictionlessRestart) {
151
- frictionlessRestart();
152
- }
153
- };
154
-
155
- const setValidChallengeTimeout = () => {
156
- const timeMillis: number = getConfig().captchas.pow.solutionTimeout;
157
- const successfullChallengeTimeout = setTimeout(() => {
158
- if (disposed) return;
159
- // Human state expired, disallow user's claim to be human
160
- updateState({ isHuman: false });
161
-
162
- events.onExpired();
163
- resetState(frictionlessState?.restart);
164
- }, timeMillis);
165
-
166
- updateState({ successfullChallengeTimeout });
167
- };
168
-
169
- // Checkbox click coords captured on the entry-point tick (or forwarded
170
- // from a session-invalidated retry). Persisted in the closure so an
171
- // eventual escalation can hand them to the escalated image/puzzle widget
172
- // via `onEscalate` — otherwise the escalated widget seeds (0, 0) into
173
- // its own salt and the entry-point telemetry is lost across the hop.
174
- let checkboxClickX = 0;
175
- let checkboxClickY = 0;
176
-
177
- const start = async (x = 0, y = 0) => {
178
- checkboxClickX = x;
179
- checkboxClickY = y;
180
- await providerRetry(
181
- async () => {
182
- if (state.loading) {
183
- return;
184
- }
185
- if (state.isHuman) {
186
- return;
187
- }
188
-
189
- // reset the state to defaults - do not reset the frictionless state
190
- resetState();
191
-
192
- // set the loading flag to true (allow UI to show some sort of loading / pending indicator while we get the captcha process going)
193
- updateState({
194
- loading: true,
195
- });
196
- updateState({ attemptCount: state.attemptCount + 1 });
197
-
198
- const config = getConfig();
199
-
200
- // check if account exists in extension
201
- const selectAccount = async () => {
202
- if (frictionlessState) {
203
- return frictionlessState.userAccount;
204
- }
205
- const ext = new (await ExtensionLoader(config.web2))();
206
- return ext.getAccount(config);
207
- };
208
-
209
- // use the passed in account (could be web3) or create a new account
210
- const user = await selectAccount();
211
- const userAccount = user.account.address;
212
-
213
- // set the account created or injected by the extension
214
- updateState({
215
- account: { account: { address: userAccount } },
216
- });
217
-
218
- // snapshot the config into the state
219
- updateState({ dappAccount: config.account.address });
220
-
221
- // allow UI to catch up with the loading state
222
- await sleep(100);
223
-
224
- // check if account has been provided in config (doesn't matter in web2 mode)
225
- if (!config.web2 && !config.userAccountAddress) {
226
- throw new ProsopoEnvError("GENERAL.ACCOUNT_NOT_FOUND", {
227
- context: {
228
- error: "Account address has not been set for web3 mode",
229
- },
230
- });
231
- }
232
-
233
- let getRandomProviderResponse = undefined;
234
-
235
- if (frictionlessState?.provider) {
236
- getRandomProviderResponse = frictionlessState.provider;
237
- } else {
238
- const currentConfig = getConfig();
239
- getRandomProviderResponse = await getProcaptchaRandomActiveProvider(
240
- currentConfig.defaultEnvironment,
241
- pickIpMode(currentConfig),
242
- { attempt: state.attemptCount, excludeUrl: previousProviderUrl },
243
- );
244
- }
245
-
246
- const providerUrl = getRandomProviderResponse.provider.url;
247
- previousProviderUrl = providerUrl;
248
-
249
- const providerApi = new ProviderApi(providerUrl, getDappAccount());
250
-
251
- // Non-blocking check (timeoutMs=0): only attach SIMD readings if
252
- // the benchmark prefetched by the catcher has already resolved.
253
- // We want this signal as early as possible — first request that
254
- // has it wins; later attach points (submission) are backups.
255
- const simdReadingsOnChallenge = frictionlessState?.getSimdReadings
256
- ? await frictionlessState.getSimdReadings(0)
257
- : undefined;
258
- const challenge = await providerApi.getPowCaptchaChallenge(
259
- userAccount,
260
- getDappAccount(),
261
- frictionlessState?.sessionId,
262
- simdReadingsOnChallenge,
263
- );
264
-
265
- if (challenge.error) {
266
- updateState({
267
- loading: false,
268
- error: {
269
- message: challenge.error.message,
270
- key: challenge.error.key || "API.UNKNOWN_ERROR",
271
- },
272
- });
273
- } else {
274
- const solution = await solvePoW(
275
- challenge.challenge,
276
- challenge.difficulty,
277
- );
278
-
279
- // Create salt with encoded coordinates if coordinates are provided
280
- let salt: string | undefined;
281
- if (x !== undefined && y !== undefined) {
282
- const coords = [x, y];
283
- const randomSalt = randomAsHex(
284
- coords
285
- .map((coord) => coord.toString(16).length + 4)
286
- .reduce((acc, curr) => acc + curr, 0),
287
- );
288
- salt = embedData(randomSalt, coords);
289
- }
290
-
291
- const signer = user.extension?.signer;
292
-
293
- if (!signer || !signer.signRaw) {
294
- throw new ProsopoEnvError("GENERAL.CANT_FIND_KEYRINGPAIR", {
295
- context: {
296
- error:
297
- "Signer is not defined, cannot sign message to prove account ownership",
298
- },
299
- });
300
- }
301
-
302
- const userTimestampSignature = await signer.signRaw({
303
- address: userAccount,
304
- data: stringToHex(challenge[ApiParams.timestamp].toString()),
305
- type: "bytes",
306
- });
307
-
308
- let encryptedBehavioralData: string | undefined;
309
-
310
- // Collect and encrypt behavioral data before submission
311
- if (
312
- frictionlessState?.encryptBehavioralData &&
313
- (frictionlessState?.behaviorCollector1 ||
314
- frictionlessState?.behaviorCollector2 ||
315
- frictionlessState?.behaviorCollector3 ||
316
- frictionlessState?.behaviorCollector4)
317
- ) {
318
- try {
319
- const behavioralData = {
320
- collector1:
321
- frictionlessState.behaviorCollector1?.getData() || [],
322
- collector2:
323
- frictionlessState.behaviorCollector2?.getData() || [],
324
- collector3:
325
- frictionlessState.behaviorCollector3?.getData() || [],
326
- collector4:
327
- frictionlessState.behaviorCollector4?.getData() || [],
328
- deviceCapability:
329
- frictionlessState.deviceCapability || "unknown",
330
- };
331
-
332
- // Pack the behavioral data before stringifying
333
- const dataToEncrypt = frictionlessState.packBehavioralData
334
- ? frictionlessState.packBehavioralData(behavioralData)
335
- : behavioralData;
336
-
337
- encryptedBehavioralData =
338
- await frictionlessState.encryptBehavioralData(
339
- JSON.stringify(dataToEncrypt),
340
- );
341
- } catch {
342
- // Silently ignore behavioral data errors - captcha should still work
343
- }
344
- }
345
-
346
- // Wait 5 secs for ongoing SIMD, else submit without
347
- const simdReadings =
348
- await getSimdReadingsForSubmit(frictionlessState);
349
- const clientMetaData = buildClientMetaData(
350
- getHoneypotValue?.(),
351
- getConfig().clientSessionId,
352
- );
353
- // Best-effort proof of fingerprint; submission proceeds without it
354
- // if a proof can't be produced (e.g. fingerprint unavailable).
355
- // Only the validator-checked keys are disclosed to keep the
356
- // payload small (see FINGERPRINT_DISCLOSURE_KEYS).
357
- let fingerprintProof: string | undefined;
358
- try {
359
- fingerprintProof = encodeFingerprintProof(
360
- await getFingerprintProof(FINGERPRINT_DISCLOSURE_KEYS),
361
- );
362
- } catch {
363
- fingerprintProof = undefined;
364
- }
365
- // Test hook for exercising the provider's fingerprint failure
366
- // path. In a browser console set:
367
- // window.__prosopoFingerprintProofTest__ = "malformed" // → image
368
- // window.__prosopoFingerprintProofTest__ = "omit" // → image
369
- // Never set in production; unset/any other value = normal proof.
370
- const fpTest = (globalThis as Record<string, unknown>)
371
- .__prosopoFingerprintProofTest__;
372
- if (fpTest === "omit") {
373
- fingerprintProof = undefined;
374
- } else if (typeof fpTest === "string" && fpTest.length > 0) {
375
- fingerprintProof = "invalid-fingerprint-proof";
376
- }
377
- const verifiedSolution = await providerApi.submitPowCaptchaSolution(
378
- challenge,
379
- getAccount().account.account.address,
380
- getDappAccount(),
381
- solution,
382
- userTimestampSignature.signature.toString(),
383
- encryptedBehavioralData,
384
- salt,
385
- simdReadings,
386
- clientMetaData,
387
- fingerprintProof,
388
- );
389
- if (disposed) return;
390
- const escalation = verifiedSolution[ApiParams.escalation];
391
- if (
392
- escalation &&
393
- (escalation[ApiParams.captchaType] === CaptchaType.image ||
394
- escalation[ApiParams.captchaType] === CaptchaType.puzzle)
395
- ) {
396
- // Provider accepted the PoW but wants the user to complete a
397
- // follow-up image/puzzle challenge. Hand off to the wrapper —
398
- // don't fire onHuman or onFailed; the wrapper mounts the next
399
- // widget and the standard success/failure path resumes there.
400
- //
401
- // Forward the trusted checkbox coords captured on this widget's
402
- // tick so the escalated widget seeds its salt with the real
403
- // (x, y) rather than defaulting to (0, 0). Mirrors the
404
- // session-invalidated retry path in ProcaptchaFrictionless.
405
- updateState({ loading: false });
406
- // (0, 0) is the untrusted-event / autoStart default —
407
- // treat it as "no coords" so the escalated widget doesn't
408
- // re-encode a fake click into its salt. Matches the
409
- // filter applied in handleSessionInvalidated.
410
- const isRealClick = checkboxClickX !== 0 || checkboxClickY !== 0;
411
- onEscalate?.(
412
- escalation[ApiParams.captchaType],
413
- escalation[ApiParams.sessionId],
414
- isRealClick
415
- ? { x: checkboxClickX, y: checkboxClickY }
416
- : undefined,
417
- );
418
- } else if (verifiedSolution[ApiParams.verified]) {
419
- updateState({
420
- isHuman: true,
421
- loading: false,
422
- });
423
-
424
- events.onHuman(
425
- encodeProcaptchaOutput({
426
- [ApiParams.providerUrl]: providerUrl,
427
- [ApiParams.user]: getAccount().account.account.address,
428
- [ApiParams.dapp]: getDappAccount(),
429
- [ApiParams.challenge]: challenge.challenge,
430
- [ApiParams.nonce]: solution,
431
- [ApiParams.timestamp]: challenge.timestamp,
432
- [ApiParams.signature]: {
433
- [ApiParams.provider]: challenge.signature.provider,
434
- [ApiParams.user]: {
435
- [ApiParams.timestamp]:
436
- userTimestampSignature.signature.toString(),
437
- },
438
- },
439
- [ApiParams.captchaType]: CaptchaType.pow,
440
- }),
441
- );
442
- setValidChallengeTimeout();
443
- } else {
444
- onFailed();
445
- }
446
- }
447
- },
448
- // Retry with the same coordinates: a bare `start` restarts at the
449
- // default (0, 0), which reads as "no real click" further down and
450
- // costs the escalated widget the entry-point telemetry.
451
- () => start(x, y),
452
- () => {
453
- resetState();
454
- },
455
- state.attemptCount,
456
- 3,
457
- );
458
- };
459
-
460
- // Set once the widget that owns this manager is torn down. A solve still in
461
- // flight can land afterwards, so the timer callbacks check it as well as
462
- // being cleared here.
463
- let disposed = false;
464
-
465
- /**
466
- * Stops this manager's challenge and solution-expiry timers without firing
467
- * any event. Left running after the widget is destroyed (reset(), a
468
- * restart, an SPA route change) they fired onExpired/onReset later on,
469
- * which also cleared the replacement widget's token from the form.
470
- */
471
- const dispose = () => {
472
- disposed = true;
473
- window.clearTimeout(Number(state.timeout));
474
- window.clearTimeout(Number(state.successfullChallengeTimeout));
475
- };
476
-
477
- return {
478
- start,
479
- resetState,
480
- dispose,
481
- };
482
- };
@@ -1,122 +0,0 @@
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
- /**
16
- * A minimal mount/unmount harness. The suite deliberately drives real DOM
17
- * events rather than calling handlers directly, because every interactive
18
- * element in this package drops untrusted events — behaviour that only shows
19
- * up when a real event carries the flag.
20
- */
21
- export interface Mounted {
22
- container: HTMLDivElement;
23
- unmount: () => void;
24
- }
25
-
26
- export const mount = (): Mounted => {
27
- const container = document.createElement("div");
28
- document.body.appendChild(container);
29
- return {
30
- container,
31
- unmount: () => {
32
- container.remove();
33
- },
34
- };
35
- };
36
-
37
- interface FireOptions {
38
- trusted?: boolean;
39
- clientX?: number;
40
- clientY?: number;
41
- touches?: { clientX: number; clientY: number }[];
42
- key?: string;
43
- }
44
-
45
- /**
46
- * jsdom marks everything it dispatches untrusted, and exposes `isTrusted` as a
47
- * non-configurable accessor onto its internal implementation object, so the
48
- * flag has to be set there rather than shadowed on the event. Anything the
49
- * components filter on untrusted events is untestable otherwise.
50
- */
51
- const setTrusted = (event: Event, trusted: boolean): void => {
52
- for (const symbol of Object.getOwnPropertySymbols(event)) {
53
- const impl: unknown = Reflect.get(event, symbol);
54
- if (impl && typeof impl === "object" && "isTrusted" in impl) {
55
- // A plain assignment would not survive: dispatchEvent stamps
56
- // isTrusted back to false on the way in, so the flag is pinned with
57
- // an accessor that swallows the write.
58
- Object.defineProperty(impl, "isTrusted", {
59
- configurable: true,
60
- get: () => trusted,
61
- set: () => undefined,
62
- });
63
- return;
64
- }
65
- }
66
- throw new Error("could not reach the jsdom event implementation");
67
- };
68
-
69
- const build = (type: string, options: FireOptions): Event => {
70
- if (undefined !== options.key) {
71
- return new KeyboardEvent(type, {
72
- bubbles: true,
73
- cancelable: true,
74
- key: options.key,
75
- });
76
- }
77
- return new MouseEvent(type, {
78
- bubbles: true,
79
- cancelable: true,
80
- clientX: options.clientX ?? 0,
81
- clientY: options.clientY ?? 0,
82
- });
83
- };
84
-
85
- /**
86
- * Dispatches a real event at the element. Touch points are plain objects:
87
- * jsdom has no Touch constructor.
88
- */
89
- export const fire = (
90
- element: Element,
91
- type: string,
92
- options: FireOptions = {},
93
- ): void => {
94
- fireAndReturn(element, type, options);
95
- };
96
-
97
- /** Like `fire`, but hands back the event so a test can inspect what the
98
- * handler did to it — whether it prevented the default form submission, say. */
99
- export const fireAndReturn = (
100
- element: Element,
101
- type: string,
102
- options: FireOptions = {},
103
- ): Event => {
104
- const event = build(type, options);
105
- setTrusted(event, options.trusted ?? true);
106
- if (options.touches) {
107
- Object.defineProperty(event, "touches", { value: options.touches });
108
- }
109
- element.dispatchEvent(event);
110
- return event;
111
- };
112
-
113
- /**
114
- * Renders coalesce onto the microtask queue, so a state change is not visible
115
- * in the DOM until it has drained.
116
- */
117
- export const settle = async (): Promise<void> => {
118
- // A macrotask turn, so a pending dynamic import resolves too — draining the
119
- // microtask queue alone is not enough for the code-split boundaries.
120
- await new Promise<void>((resolve: () => void) => setTimeout(resolve, 0));
121
- await Promise.resolve();
122
- };