@prosopo/procaptcha-pow 2.8.27 → 2.9.4

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,164 @@
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 { loadI18next, useTranslation } from "@prosopo/locale";
16
+ import { buildUpdateState, useProcaptcha } from "@prosopo/procaptcha-common";
17
+ import { Checkbox, Honeypot } from "@prosopo/procaptcha-common";
18
+ import { ModeEnum, type ProcaptchaProps } from "@prosopo/types";
19
+ import { darkTheme, lightTheme } from "@prosopo/widget-skeleton";
20
+ import { useEffect, useRef, useState } from "react";
21
+ import { Manager } from "../services/Manager.js";
22
+
23
+ // Define the same event name as in the bundle for consistency
24
+ const PROCAPTCHA_EXECUTE_EVENT = "procaptcha:execute";
25
+
26
+ const Procaptcha = (props: ProcaptchaProps) => {
27
+ const { t, ready: isTranslationReady } = useTranslation();
28
+ const config = props.config;
29
+ const i18n = props.i18n;
30
+ const theme = "light" === config.theme ? lightTheme : darkTheme;
31
+ const frictionlessState = props.frictionlessState; // Set up Session ID and Provider if they exist
32
+ const callbacks = props.callbacks || {};
33
+ const [state, _updateState] = useProcaptcha(useState, useRef);
34
+ const [loading, setLoading] = useState(false);
35
+ // get the state update mechanism
36
+ const updateState = buildUpdateState(state, _updateState);
37
+ const hpRef = useRef<HTMLInputElement>(null);
38
+ const manager = useRef(
39
+ Manager(
40
+ config,
41
+ state,
42
+ updateState,
43
+ callbacks,
44
+ frictionlessState,
45
+ props.onEscalate,
46
+ () => hpRef.current?.value || undefined,
47
+ ),
48
+ );
49
+
50
+ useEffect(() => {
51
+ if (config.language) {
52
+ if (i18n) {
53
+ if (i18n.language !== config.language) {
54
+ i18n.changeLanguage(config.language).then((r) => r);
55
+ }
56
+ } else {
57
+ loadI18next(false).then((i18n) => {
58
+ if (i18n.language !== config.language)
59
+ i18n.changeLanguage(config.language).then((r) => r);
60
+ });
61
+ }
62
+ }
63
+ }, [i18n, config.language]);
64
+
65
+ useEffect(() => {
66
+ if (state.error) {
67
+ setLoading(false);
68
+ if (state.error.key === "CAPTCHA.NO_SESSION_FOUND" && frictionlessState) {
69
+ setTimeout(() => {
70
+ frictionlessState.restart();
71
+ }, 100);
72
+ }
73
+ }
74
+ }, [state.error, frictionlessState]);
75
+
76
+ // Add event listener for the execute event (works for invisible mode)
77
+ useEffect(() => {
78
+ // Only set up event listener if in invisible mode
79
+ if (config.mode === ModeEnum.invisible) {
80
+ // Event handler for when execute() is called
81
+ const handleExecuteEvent = (event: Event) => {
82
+ // Directly start the verification process without showing any UI
83
+ try {
84
+ // Start the PoW verification process
85
+ manager.current.start();
86
+ } catch (error) {
87
+ console.error("Error starting PoW verification:", error);
88
+ }
89
+ };
90
+
91
+ document.addEventListener(PROCAPTCHA_EXECUTE_EVENT, handleExecuteEvent);
92
+
93
+ // Cleanup function to remove event listener
94
+ return () => {
95
+ document.removeEventListener(
96
+ PROCAPTCHA_EXECUTE_EVENT,
97
+ handleExecuteEvent,
98
+ );
99
+ };
100
+ }
101
+
102
+ // Return empty cleanup function when not in invisible mode
103
+ return () => {};
104
+ }, [config.mode]);
105
+
106
+ const honeypot = frictionlessState?.hp ? (
107
+ <Honeypot ref={hpRef} encodedQuestion={frictionlessState.hp} />
108
+ ) : null;
109
+
110
+ if (config.mode === ModeEnum.invisible) {
111
+ // Invisible mode renders no checkbox, but we still render the honeypot
112
+ // so bots that scan the DOM for inputs find a tempting target.
113
+ return honeypot;
114
+ }
115
+
116
+ return (
117
+ <>
118
+ {honeypot}
119
+ <Checkbox
120
+ checked={state.isHuman}
121
+ theme={theme}
122
+ onChange={async (event: React.MouseEvent | React.TouchEvent) => {
123
+ if (loading) {
124
+ return;
125
+ }
126
+ setLoading(true);
127
+
128
+ // Capture click coordinates
129
+ let x = 0;
130
+ let y = 0;
131
+
132
+ // Try to get coordinates from the change event's underlying mouse event
133
+ // The original mouse event might be available in the event chain
134
+ const mouseOrTouchEvent = event.nativeEvent;
135
+ if (!mouseOrTouchEvent.isTrusted) {
136
+ // Don't capture coordinates for non-trusted events
137
+ } else if (
138
+ "touches" in mouseOrTouchEvent &&
139
+ mouseOrTouchEvent.touches.length > 0 &&
140
+ mouseOrTouchEvent.touches[0]
141
+ ) {
142
+ x = mouseOrTouchEvent.touches[0].clientX;
143
+ y = mouseOrTouchEvent.touches[0].clientY;
144
+ } else if (
145
+ "clientX" in mouseOrTouchEvent &&
146
+ "clientY" in mouseOrTouchEvent
147
+ ) {
148
+ x = mouseOrTouchEvent.clientX;
149
+ y = mouseOrTouchEvent.clientY;
150
+ }
151
+
152
+ await manager.current.start(x, y);
153
+ setLoading(false);
154
+ }}
155
+ labelText={isTranslationReady ? t("WIDGET.I_AM_HUMAN") : ""}
156
+ error={state.error?.message}
157
+ aria-label="human checkbox"
158
+ loading={loading}
159
+ />
160
+ </>
161
+ );
162
+ };
163
+
164
+ export default Procaptcha;
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
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
+ export * from "./components/ProcaptchaWidget.js";
15
+ export * from "./components/ProcaptchaPoW.js";
@@ -0,0 +1,390 @@
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
+ CaptchaType,
29
+ type FrictionlessState,
30
+ type ProcaptchaCallbacks,
31
+ type ProcaptchaClientConfigInput,
32
+ ProcaptchaConfigSchema,
33
+ type ProcaptchaEscalationHandler,
34
+ type ProcaptchaState,
35
+ type ProcaptchaStateUpdateFn,
36
+ encodeProcaptchaOutput,
37
+ } from "@prosopo/types";
38
+ import { embedData, sleep } from "@prosopo/util";
39
+ import { solvePoW } from "@prosopo/util";
40
+ import { randomAsHex } from "@prosopo/util-crypto";
41
+
42
+ export const Manager = (
43
+ configInput: ProcaptchaClientConfigInput,
44
+ state: ProcaptchaState,
45
+ onStateUpdate: ProcaptchaStateUpdateFn,
46
+ callbacks: ProcaptchaCallbacks,
47
+ frictionlessState?: FrictionlessState,
48
+ onEscalate?: ProcaptchaEscalationHandler,
49
+ // Reads the live honeypot input value at submit time. Returns undefined
50
+ // when the input doesn't exist (honeypot disabled) or hasn't been filled.
51
+ // Bots that auto-fill text inputs populate it; humans never see it.
52
+ getHoneypotValue?: () => string | undefined,
53
+ ) => {
54
+ const events = getDefaultEvents(callbacks);
55
+
56
+ const defaultState = (): Partial<ProcaptchaState> => {
57
+ return {
58
+ // note order matters! see buildUpdateState. These fields are set in order, so disable modal first, then set loading to false, etc.
59
+ showModal: false,
60
+ loading: false,
61
+ index: 0,
62
+ challenge: undefined,
63
+ solutions: undefined,
64
+ isHuman: false,
65
+ captchaApi: undefined,
66
+ account: undefined,
67
+ // don't handle timeout here, this should be handled by the state management
68
+ };
69
+ };
70
+
71
+ const clearTimeout = () => {
72
+ // clear the timeout
73
+ window.clearTimeout(Number(state.timeout));
74
+ // then clear the timeout from the state
75
+ updateState({ timeout: undefined });
76
+ };
77
+
78
+ const onFailed = () => {
79
+ updateState({
80
+ isHuman: false,
81
+ loading: false,
82
+ });
83
+ events.onFailed();
84
+ resetState(frictionlessState?.restart);
85
+ };
86
+
87
+ const clearSuccessfulChallengeTimeout = () => {
88
+ // clear the timeout
89
+ window.clearTimeout(Number(state.successfullChallengeTimeout));
90
+ // then clear the timeout from the state
91
+ updateState({ successfullChallengeTimeout: undefined });
92
+ };
93
+
94
+ const getConfig = () => {
95
+ const config: ProcaptchaClientConfigInput = {
96
+ userAccountAddress: configInput.userAccountAddress || "",
97
+ ...configInput,
98
+ };
99
+
100
+ // overwrite the account in use with the one in state if it exists. Reduces likelihood of bugs where the user
101
+ // changes account in the middle of the captcha process.
102
+ if (state.account) {
103
+ config.userAccountAddress = state.account.account.address;
104
+ }
105
+
106
+ return ProcaptchaConfigSchema.parse(config);
107
+ };
108
+
109
+ const getAccount = () => {
110
+ if (!state.account) {
111
+ throw new ProsopoEnvError("GENERAL.ACCOUNT_NOT_FOUND", {
112
+ context: { error: "Account not loaded" },
113
+ });
114
+ }
115
+ const account: Account = state.account;
116
+ return { account };
117
+ };
118
+
119
+ const getDappAccount = () => {
120
+ if (!state.dappAccount) {
121
+ throw new ProsopoEnvError("GENERAL.SITE_KEY_MISSING");
122
+ }
123
+
124
+ const dappAccount: string = state.dappAccount;
125
+ return dappAccount;
126
+ };
127
+
128
+ // get the state update mechanism
129
+ const updateState = buildUpdateState(state, onStateUpdate);
130
+
131
+ const resetState = (frictionlessRestart?: () => void) => {
132
+ // clear timeout just in case a timer is still active (shouldn't be)
133
+ clearTimeout();
134
+ clearSuccessfulChallengeTimeout();
135
+ updateState(defaultState());
136
+ events.onReset();
137
+ // reset the frictionless state if necessary
138
+ if (frictionlessRestart) {
139
+ frictionlessRestart();
140
+ }
141
+ };
142
+
143
+ const setValidChallengeTimeout = () => {
144
+ const timeMillis: number = getConfig().captchas.pow.solutionTimeout;
145
+ const successfullChallengeTimeout = setTimeout(() => {
146
+ // Human state expired, disallow user's claim to be human
147
+ updateState({ isHuman: false });
148
+
149
+ events.onExpired();
150
+ resetState(frictionlessState?.restart);
151
+ }, timeMillis);
152
+
153
+ updateState({ successfullChallengeTimeout });
154
+ };
155
+
156
+ const start = async (x = 0, y = 0) => {
157
+ await providerRetry(
158
+ async () => {
159
+ if (state.loading) {
160
+ return;
161
+ }
162
+ if (state.isHuman) {
163
+ return;
164
+ }
165
+
166
+ // reset the state to defaults - do not reset the frictionless state
167
+ resetState();
168
+
169
+ // set the loading flag to true (allow UI to show some sort of loading / pending indicator while we get the captcha process going)
170
+ updateState({
171
+ loading: true,
172
+ });
173
+ updateState({ attemptCount: state.attemptCount + 1 });
174
+
175
+ const config = getConfig();
176
+
177
+ // check if account exists in extension
178
+ const selectAccount = async () => {
179
+ if (frictionlessState) {
180
+ return frictionlessState.userAccount;
181
+ }
182
+ const ext = new (await ExtensionLoader(config.web2))();
183
+ return ext.getAccount(config);
184
+ };
185
+
186
+ // use the passed in account (could be web3) or create a new account
187
+ const user = await selectAccount();
188
+ const userAccount = user.account.address;
189
+
190
+ // set the account created or injected by the extension
191
+ updateState({
192
+ account: { account: { address: userAccount } },
193
+ });
194
+
195
+ // snapshot the config into the state
196
+ updateState({ dappAccount: config.account.address });
197
+
198
+ // allow UI to catch up with the loading state
199
+ await sleep(100);
200
+
201
+ // check if account has been provided in config (doesn't matter in web2 mode)
202
+ if (!config.web2 && !config.userAccountAddress) {
203
+ throw new ProsopoEnvError("GENERAL.ACCOUNT_NOT_FOUND", {
204
+ context: {
205
+ error: "Account address has not been set for web3 mode",
206
+ },
207
+ });
208
+ }
209
+
210
+ let getRandomProviderResponse = undefined;
211
+
212
+ if (frictionlessState?.provider) {
213
+ getRandomProviderResponse = frictionlessState.provider;
214
+ } else {
215
+ getRandomProviderResponse = await getProcaptchaRandomActiveProvider(
216
+ getConfig().defaultEnvironment,
217
+ );
218
+ }
219
+
220
+ const providerUrl = getRandomProviderResponse.provider.url;
221
+
222
+ const providerApi = new ProviderApi(providerUrl, getDappAccount());
223
+
224
+ // Non-blocking check (timeoutMs=0): only attach SIMD readings if
225
+ // the benchmark prefetched by the catcher has already resolved.
226
+ // We want this signal as early as possible — first request that
227
+ // has it wins; later attach points (submission) are backups.
228
+ const simdReadingsOnChallenge = frictionlessState?.getSimdReadings
229
+ ? await frictionlessState.getSimdReadings(0)
230
+ : undefined;
231
+ const challenge = await providerApi.getPowCaptchaChallenge(
232
+ userAccount,
233
+ getDappAccount(),
234
+ frictionlessState?.sessionId,
235
+ simdReadingsOnChallenge,
236
+ );
237
+
238
+ if (challenge.error) {
239
+ updateState({
240
+ loading: false,
241
+ error: {
242
+ message: challenge.error.message,
243
+ key: challenge.error.key || "API.UNKNOWN_ERROR",
244
+ },
245
+ });
246
+ } else {
247
+ const solution = await solvePoW(
248
+ challenge.challenge,
249
+ challenge.difficulty,
250
+ );
251
+
252
+ // Create salt with encoded coordinates if coordinates are provided
253
+ let salt: string | undefined;
254
+ if (x !== undefined && y !== undefined) {
255
+ const coords = [x, y];
256
+ const randomSalt = randomAsHex(
257
+ coords
258
+ .map((coord) => coord.toString(16).length + 4)
259
+ .reduce((acc, curr) => acc + curr, 0),
260
+ );
261
+ salt = embedData(randomSalt, coords);
262
+ }
263
+
264
+ const signer = user.extension?.signer;
265
+
266
+ if (!signer || !signer.signRaw) {
267
+ throw new ProsopoEnvError("GENERAL.CANT_FIND_KEYRINGPAIR", {
268
+ context: {
269
+ error:
270
+ "Signer is not defined, cannot sign message to prove account ownership",
271
+ },
272
+ });
273
+ }
274
+
275
+ const userTimestampSignature = await signer.signRaw({
276
+ address: userAccount,
277
+ data: stringToHex(challenge[ApiParams.timestamp].toString()),
278
+ type: "bytes",
279
+ });
280
+
281
+ let encryptedBehavioralData: string | undefined;
282
+
283
+ // Collect and encrypt behavioral data before submission
284
+ if (
285
+ frictionlessState?.encryptBehavioralData &&
286
+ (frictionlessState?.behaviorCollector1 ||
287
+ frictionlessState?.behaviorCollector2 ||
288
+ frictionlessState?.behaviorCollector3)
289
+ ) {
290
+ try {
291
+ const behavioralData = {
292
+ collector1:
293
+ frictionlessState.behaviorCollector1?.getData() || [],
294
+ collector2:
295
+ frictionlessState.behaviorCollector2?.getData() || [],
296
+ collector3:
297
+ frictionlessState.behaviorCollector3?.getData() || [],
298
+ deviceCapability:
299
+ frictionlessState.deviceCapability || "unknown",
300
+ };
301
+
302
+ // Pack the behavioral data before stringifying
303
+ const dataToEncrypt = frictionlessState.packBehavioralData
304
+ ? frictionlessState.packBehavioralData(behavioralData)
305
+ : behavioralData;
306
+
307
+ encryptedBehavioralData =
308
+ await frictionlessState.encryptBehavioralData(
309
+ JSON.stringify(dataToEncrypt),
310
+ );
311
+ } catch {
312
+ // Silently ignore behavioral data errors - captcha should still work
313
+ }
314
+ }
315
+
316
+ const simdReadings = frictionlessState?.getSimdReadings
317
+ ? await frictionlessState.getSimdReadings()
318
+ : undefined;
319
+ const hpValue = getHoneypotValue?.();
320
+ const clientMetaData = hpValue ? { hp: hpValue } : undefined;
321
+ const verifiedSolution = await providerApi.submitPowCaptchaSolution(
322
+ challenge,
323
+ getAccount().account.account.address,
324
+ getDappAccount(),
325
+ solution,
326
+ userTimestampSignature.signature.toString(),
327
+ config.captchas.pow.verifiedTimeout,
328
+ encryptedBehavioralData,
329
+ salt,
330
+ simdReadings,
331
+ clientMetaData,
332
+ );
333
+ const escalation = verifiedSolution[ApiParams.escalation];
334
+ if (
335
+ escalation &&
336
+ (escalation[ApiParams.captchaType] === CaptchaType.image ||
337
+ escalation[ApiParams.captchaType] === CaptchaType.puzzle)
338
+ ) {
339
+ // Provider accepted the PoW but wants the user to complete a
340
+ // follow-up image/puzzle challenge. Hand off to the wrapper —
341
+ // don't fire onHuman or onFailed; the wrapper mounts the next
342
+ // widget and the standard success/failure path resumes there.
343
+ updateState({ loading: false });
344
+ onEscalate?.(
345
+ escalation[ApiParams.captchaType],
346
+ escalation[ApiParams.sessionId],
347
+ );
348
+ } else if (verifiedSolution[ApiParams.verified]) {
349
+ updateState({
350
+ isHuman: true,
351
+ loading: false,
352
+ });
353
+
354
+ events.onHuman(
355
+ encodeProcaptchaOutput({
356
+ [ApiParams.providerUrl]: providerUrl,
357
+ [ApiParams.user]: getAccount().account.account.address,
358
+ [ApiParams.dapp]: getDappAccount(),
359
+ [ApiParams.challenge]: challenge.challenge,
360
+ [ApiParams.nonce]: solution,
361
+ [ApiParams.timestamp]: challenge.timestamp,
362
+ [ApiParams.signature]: {
363
+ [ApiParams.provider]: challenge.signature.provider,
364
+ [ApiParams.user]: {
365
+ [ApiParams.timestamp]:
366
+ userTimestampSignature.signature.toString(),
367
+ },
368
+ },
369
+ }),
370
+ );
371
+ setValidChallengeTimeout();
372
+ } else {
373
+ onFailed();
374
+ }
375
+ }
376
+ },
377
+ start,
378
+ () => {
379
+ resetState();
380
+ },
381
+ state.attemptCount,
382
+ 3,
383
+ );
384
+ };
385
+
386
+ return {
387
+ start,
388
+ resetState,
389
+ };
390
+ };
@@ -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
+ }