diff --git a/README.md b/README.md index 7a8c9a1260..41bc5d4855 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,10 @@ const payload = JSON.parse(event.body) // parse the procaptcha response, which is a JSON string const procaptchaResponse = JSON.parse(payload[ApiParams.procaptchaResponse]) -// send the +// initialise the `ProsopoServer` class +const prosopoServer = new ProsopoServer(config, pair) + +// check if the captcha response is verified if (await prosopoServer.isVerified(procaptchaResponse)) { // perform CAPTCHA protected action } @@ -269,6 +272,50 @@ if (await prosopoServer.isVerified(procaptchaResponse)) { There is an example TypeScript server side implementation in [demos/client-example-server](https://github.com/prosopo/captcha/tree/main/demos/client-example-server). +#### Specifying timeouts + +Custom timeouts can be specified for the length of time in which a user has to solve the CAPTCHA challenge. The defaults are as follows: + +```typescript +const defaultCaptchaTimeouts = { + image: { + // The timeframe in which a user must complete an image captcha (1 minute) + challengeTimeout: 60000, + // The timeframe in which an image captcha solution remains valid on the page before timing out (2 minutes) + solutionTimeout: 60000 * 2, + // The timeframe in which an image captcha solution must be verified server side (3 minutes) + verifiedTimeout: 60000 * 3, + // The time in milliseconds that a cached, verified, image captcha solution is valid for (15 minutes) + cachedTimeout: 60000 * 15, + }, + pow: { + // The timeframe in which a pow captcha solution remains valid on the page before timing out (1 minute) + challengeTimeout: 60000, + // The timeframe in which a pow captcha must be completed and verified (2 minutes) + solutionTimeout: 60000 * 2, + // The time in milliseconds that a Provider cached, verified, pow captcha solution is valid for (3 minutes) + cachedTimeout: 60000 * 3, + }, +} +``` + +To specify timeouts using API verification, pass the above object in a field called `timeouts`, implementing one or more of the timeouts. + +```typescript +// send a POST application/json request to the API endpoint +response = POST('https://api.prosopo.io/siteverify', { + ... + timeouts: defaultCaptchaTimeouts, // add timeouts object here +}) +``` + +To specify timeouts using the verification package, pass the above object in the `timeouts` field of the `ProsopoServer` config, implementing one or more of the timeouts. + +```typescript +config = { timeouts: defaultCaptchaTimeouts, ...config } +const prosopoServer = new ProsopoServer(config, pair) +``` + ## Rendering different CAPTCHA types with Procaptcha ### Frictionless CAPTCHA diff --git a/packages/api/src/api/ProviderApi.ts b/packages/api/src/api/ProviderApi.ts index 27dd8005f3..f632b5642d 100644 --- a/packages/api/src/api/ProviderApi.ts +++ b/packages/api/src/api/ProviderApi.ts @@ -26,8 +26,9 @@ import { NetworkConfig, PowCaptchaSolutionResponse, ProviderRegistered, + ServerPowCaptchaVerifyRequestBodyType, StoredEvents, - SubmitPowCaptchaSolutionBodyType, + SubmitPowCaptchaSolutionBody, VerificationResponse, VerifySolutionBodyType, } from '@prosopo/types' @@ -110,10 +111,11 @@ export default class ProviderApi extends HttpClientBase implements ProviderApi { userAccount: AccountId, dappAccount: AccountId, randomProvider: RandomProvider, - nonce: number + nonce: number, + timeout?: number ): Promise { const { blockNumber } = randomProvider - const body: SubmitPowCaptchaSolutionBodyType = { + const body = SubmitPowCaptchaSolutionBody.parse({ [ApiParams.blockNumber]: blockNumber, [ApiParams.challenge]: challenge.challenge, [ApiParams.difficulty]: challenge.difficulty, @@ -122,7 +124,8 @@ export default class ProviderApi extends HttpClientBase implements ProviderApi { [ApiParams.user]: userAccount.toString(), [ApiParams.dapp]: dappAccount.toString(), [ApiParams.nonce]: nonce, - } + [ApiParams.verifiedTimeout]: timeout, + }) return this.post(ApiPaths.SubmitPowCaptchaSolution, body) } @@ -138,7 +141,16 @@ export default class ProviderApi extends HttpClientBase implements ProviderApi { return this.fetch(ApiPaths.GetProviderDetails) } - public submitPowCaptchaVerify(challenge: string, dapp: string): Promise { - return this.post(ApiPaths.ServerPowCaptchaVerify, { [ApiParams.challenge]: challenge, [ApiParams.dapp]: dapp }) + public submitPowCaptchaVerify( + challenge: string, + dapp: string, + recencyLimit: number + ): Promise { + const body: ServerPowCaptchaVerifyRequestBodyType = { + [ApiParams.challenge]: challenge, + [ApiParams.dapp]: dapp, + [ApiParams.verifiedTimeout]: recencyLimit, + } + return this.post(ApiPaths.ServerPowCaptchaVerify, body) } } diff --git a/packages/common/src/locales/en.json b/packages/common/src/locales/en.json index 7dcb2a261c..b8f66d1eeb 100644 --- a/packages/common/src/locales/en.json +++ b/packages/common/src/locales/en.json @@ -129,6 +129,8 @@ "PAYMENT_INFO_NOT_FOUND": "Payment info not found for given block and transaction hashes", "USER_VERIFIED": "User verified", "USER_NOT_VERIFIED": "User not verified", + "USER_NOT_VERIFIED_TIME_EXPIRED": "User not verified. Captcha solution has expired.", + "USER_NOT_VERIFIED_NO_SOLUTION": "User not verified. No captcha solution found.", "UNKNOWN": "Unknown API error" }, "CLI": { diff --git a/packages/contract/src/contract/block.ts b/packages/contract/src/contract/block.ts index 7b3bfec3df..112afff58f 100644 --- a/packages/contract/src/contract/block.ts +++ b/packages/contract/src/contract/block.ts @@ -29,3 +29,20 @@ export const getBlockTimeMs = (api: ApiPromise): number => { export const getCurrentBlockNumber = async (api: ApiPromise): Promise => { return (await api.rpc.chain.getBlock()).block.header.number.toNumber() } + +/** + * Verify the time since the blockNumber is equal to or less than the maxVerifiedTime. + * @param api + * @param maxVerifiedTime + * @param blockNumber + */ +export const verifyRecency = async (api: ApiPromise, blockNumber: number, maxVerifiedTime: number) => { + // Get the current block number + const currentBlock = await getCurrentBlockNumber(api) + // Calculate how many blocks have passed since the blockNumber + const blocksPassed = currentBlock - blockNumber + // Get the expected block time + const blockTime = getBlockTimeMs(api) + // Check if the time since the last correct captcha is within the limit + return blockTime * blocksPassed <= maxVerifiedTime +} diff --git a/packages/procaptcha-bundle/src/index.tsx b/packages/procaptcha-bundle/src/index.tsx index fe2d59dea2..0c96643582 100644 --- a/packages/procaptcha-bundle/src/index.tsx +++ b/packages/procaptcha-bundle/src/index.tsx @@ -18,6 +18,7 @@ import { FeaturesEnum, NetworkNamesSchema, ProcaptchaClientConfigInput, + ProcaptchaClientConfigOutput, ProcaptchaConfigSchema, ProcaptchaOutput, } from '@prosopo/types' @@ -56,7 +57,7 @@ const extractParams = (name: string) => { return { onloadUrlCallback: undefined, renderExplicit: undefined } } -const getConfig = (siteKey?: string) => { +const getConfig = (siteKey?: string): ProcaptchaClientConfigOutput => { if (!siteKey) { siteKey = process.env.PROSOPO_SITE_KEY || '' } @@ -106,15 +107,23 @@ const customThemeSet = new Set(['light', 'dark']) const validateTheme = (themeAttribute: string): 'light' | 'dark' => customThemeSet.has(themeAttribute) ? (themeAttribute as 'light' | 'dark') : 'light' +/** + * Set the timeout for a solved captcha, after which point the captcha will be considered invalid and the captcha widget + * will re-render. The same value is used for PoW and image captcha. + * @param renderOptions + * @param element + * @param config + */ const setValidChallengeLength = ( renderOptions: ProcaptchaRenderOptions | undefined, element: Element, - config: ProcaptchaClientConfigInput + config: ProcaptchaClientConfigOutput ) => { const challengeValidLengthAttribute = renderOptions?.['challenge-valid-length'] || element.getAttribute('data-challenge-valid-length') if (challengeValidLengthAttribute) { - config.challengeValidLength = parseInt(challengeValidLengthAttribute) + config.captchas.image.solutionTimeout = parseInt(challengeValidLengthAttribute) + config.captchas.pow.solutionTimeout = parseInt(challengeValidLengthAttribute) } } @@ -223,7 +232,7 @@ function setUserCallbacks( const renderLogic = ( elements: Element[], - config: ProcaptchaClientConfigInput, + config: ProcaptchaClientConfigOutput, renderOptions?: ProcaptchaRenderOptions ) => { elements.forEach((element) => { diff --git a/packages/procaptcha-pow/src/Services/Manager.ts b/packages/procaptcha-pow/src/Services/Manager.ts index 19a5f4aafe..2b343ade70 100644 --- a/packages/procaptcha-pow/src/Services/Manager.ts +++ b/packages/procaptcha-pow/src/Services/Manager.ts @@ -25,7 +25,7 @@ import { ApiPromise } from '@polkadot/api/promise/Api' import { ExtensionWeb2 } from '@prosopo/account' import { Keyring } from '@polkadot/keyring' import { ProsopoCaptchaContract, wrapQuery } from '@prosopo/contract' -import { ProsopoContractError, ProsopoEnvError, trimProviderUrl } from '@prosopo/common' +import { ProsopoEnvError, trimProviderUrl } from '@prosopo/common' import { ProviderApi } from '@prosopo/api' import { RandomProvider } from '@prosopo/captcha-contract/types-returns' import { WsProvider } from '@polkadot/rpc-provider/ws' @@ -40,6 +40,8 @@ export const Manager = ( onStateUpdate: ProcaptchaStateUpdateFn, callbacks: ProcaptchaCallbacks ) => { + const events = getDefaultEvents(onStateUpdate, state, callbacks) + const defaultState = (): Partial => { return { // note order matters! see buildUpdateState. These fields are set in order, so disable modal first, then set loading to false, etc. @@ -120,14 +122,6 @@ export const Manager = ( return dappAccount } - const getBlockNumber = () => { - if (!state.blockNumber) { - throw new ProsopoContractError('CAPTCHA.INVALID_BLOCK_NO', { context: { error: 'Block number not found' } }) - } - const blockNumber: number = state.blockNumber - return blockNumber - } - // get the state update mechanism const updateState = buildUpdateState(state, onStateUpdate) @@ -137,6 +131,18 @@ export const Manager = ( updateState(defaultState()) } + const setValidChallengeTimeout = () => { + const timeMillis: number = getConfig().captchas.pow.solutionTimeout + const successfullChallengeTimeout = setTimeout(() => { + // Human state expired, disallow user's claim to be human + updateState({ isHuman: false }) + + events.onExpired() + }, timeMillis) + + updateState({ successfullChallengeTimeout }) + } + const start = async () => { if (state.loading) { return @@ -200,7 +206,8 @@ export const Manager = ( getAccount().account.address, getDappAccount(), getRandomProviderResponse, - solution + solution, + config.captchas.pow.verifiedTimeout ) if (verifiedSolution[ApiParams.verified]) { updateState({ @@ -214,6 +221,7 @@ export const Manager = ( [ApiParams.challenge]: challenge.challenge, [ApiParams.blockNumber]: getRandomProviderResponse.blockNumber, }) + setValidChallengeTimeout() } } diff --git a/packages/procaptcha-react/src/components/ProcaptchaWidget.tsx b/packages/procaptcha-react/src/components/ProcaptchaWidget.tsx index af38d433f4..311a98183f 100644 --- a/packages/procaptcha-react/src/components/ProcaptchaWidget.tsx +++ b/packages/procaptcha-react/src/components/ProcaptchaWidget.tsx @@ -29,7 +29,7 @@ import { } from '@prosopo/web-components' import { Logo } from '@prosopo/web-components' import { Manager } from '@prosopo/procaptcha' -import { ProcaptchaProps } from '@prosopo/types' +import { ProcaptchaConfigSchema, ProcaptchaProps } from '@prosopo/types' import { useProcaptcha } from '@prosopo/procaptcha-common' import { useRef, useState } from 'react' import CaptchaComponent from './CaptchaComponent.js' @@ -37,7 +37,7 @@ import Collector from './collector.js' import Modal from './Modal.js' const ProcaptchaWidget = (props: ProcaptchaProps) => { - const config = props.config + const config = ProcaptchaConfigSchema.parse(props.config) const callbacks = props.callbacks || {} const [state, updateState] = useProcaptcha(useState, useRef) const manager = Manager(config, state, updateState, callbacks) diff --git a/packages/procaptcha/src/modules/Manager.ts b/packages/procaptcha/src/modules/Manager.ts index 8160c92125..d4d1fea4c9 100644 --- a/packages/procaptcha/src/modules/Manager.ts +++ b/packages/procaptcha/src/modules/Manager.ts @@ -79,7 +79,7 @@ const getNetwork = (config: ProcaptchaClientConfigOutput) => { * The state operator. This is used to mutate the state of Procaptcha during the captcha process. State updates are published via the onStateUpdate callback. This should be used by frontends, e.g. react, to maintain the state of Procaptcha across renders. */ export function Manager( - configOptional: ProcaptchaClientConfigInput, + configOptional: ProcaptchaClientConfigOutput, state: ProcaptchaState, onStateUpdate: ProcaptchaStateUpdateFn, callbacks: ProcaptchaCallbacks @@ -192,7 +192,7 @@ export function Manager( account.account.address, procaptchaStorage.blockNumber, undefined, - configOptional.challengeValidLength + configOptional.captchas.image.cachedTimeout ) if (verifyDappUserResponse.verified) { updateState({ isHuman: true, loading: false }) @@ -234,9 +234,11 @@ export function Manager( throw new ProsopoApiError('DEVELOPER.PROVIDER_NO_CAPTCHA') } - // setup timeout + // setup timeout, taking the timeout from the individual captcha or the global default const timeMillis: number = challenge.captchas - .map((captcha: CaptchaWithProof) => captcha.captcha.timeLimitMs || 30 * 1000) + .map( + (captcha: CaptchaWithProof) => captcha.captcha.timeLimitMs || config.captchas.image.challengeTimeout + ) .reduce((a: number, b: number) => a + b) const timeout = setTimeout(() => { events.onChallengeExpired() @@ -431,7 +433,7 @@ export function Manager( } const setValidChallengeTimeout = () => { - const timeMillis: number = configOptional.challengeValidLength || 120 * 1000 // default to 2 minutes + const timeMillis: number = configOptional.captchas.image.solutionTimeout const successfullChallengeTimeout = setTimeout(() => { // Human state expired, disallow user's claim to be human updateState({ isHuman: false }) diff --git a/packages/provider/src/api/captcha.ts b/packages/provider/src/api/captcha.ts index df066dd02d..2ca4d7af91 100644 --- a/packages/provider/src/api/captcha.ts +++ b/packages/provider/src/api/captcha.ts @@ -95,7 +95,7 @@ export function prosopoRouter(env: ProviderEnvironment): Router { ) /** - * Receives solved CAPTCHA challenges, store to database, and check against solution commitment + * Receives solved CAPTCHA challenges from the user, stores to database, and checks against solution commitment * * @param {string} userAccount - Dapp User id * @param {string} dappAccount - Dapp Contract AccountId @@ -111,6 +111,7 @@ export function prosopoRouter(env: ProviderEnvironment): Router { } try { + // TODO allow the dapp to override the length of time that the request hash is valid for const result: DappUserSolutionResult = await tasks.dappUserSolution( parsed[ApiParams.user], parsed[ApiParams.dapp], @@ -148,25 +149,38 @@ export function prosopoRouter(env: ProviderEnvironment): Router { ? tasks.getDappUserCommitmentById(parsed.commitmentId) : tasks.getDappUserCommitmentByAccount(parsed.user)) + // No solution exists if (!solution) { tasks.logger.debug('Not verified - no solution found') - return res.json({ + const noSolutionResponse: VerificationResponse = { + [ApiParams.status]: req.t('API.USER_NOT_VERIFIED_NO_SOLUTION'), + [ApiParams.verified]: false, + } + return res.json(noSolutionResponse) + } + + // A solution exists but is disapproved + if (solution.status === CaptchaStatus.disapproved) { + const disapprovedResponse: VerificationResponse = { [ApiParams.status]: req.t('API.USER_NOT_VERIFIED'), [ApiParams.verified]: false, - }) + } + return res.json(disapprovedResponse) } + // Check if solution was completed recently if (parsed.maxVerifiedTime) { const currentBlockNumber = await getCurrentBlockNumber(tasks.contract.api) const blockTimeMs = getBlockTimeMs(tasks.contract.api) const timeSinceCompletion = (currentBlockNumber - solution.completedAt) * blockTimeMs - const verificationResponse: VerificationResponse = { - [ApiParams.status]: req.t('API.USER_NOT_VERIFIED'), - [ApiParams.verified]: false, - } + // A solution exists but has timed out if (timeSinceCompletion > parsed.maxVerifiedTime) { + const expiredResponse: VerificationResponse = { + [ApiParams.status]: req.t('API.USER_NOT_VERIFIED_TIME_EXPIRED'), + [ApiParams.verified]: false, + } tasks.logger.debug('Not verified - time run out') - return res.json(verificationResponse) + return res.json(expiredResponse) } } @@ -191,9 +205,9 @@ export function prosopoRouter(env: ProviderEnvironment): Router { */ router.post(ApiPaths.ServerPowCaptchaVerify, async (req, res, next) => { try { - const { challenge, dapp } = ServerPowCaptchaVerifyRequestBody.parse(req.body) + const { challenge, dapp, verifiedTimeout } = ServerPowCaptchaVerifyRequestBody.parse(req.body) - const approved = await tasks.serverVerifyPowCaptchaSolution(dapp, challenge) + const approved = await tasks.serverVerifyPowCaptchaSolution(dapp, challenge, verifiedTimeout) const verificationResponse: VerificationResponse = { status: req.t(approved ? 'API.USER_VERIFIED' : 'API.USER_NOT_VERIFIED'), @@ -244,10 +258,16 @@ export function prosopoRouter(env: ProviderEnvironment): Router { */ router.post(ApiPaths.SubmitPowCaptchaSolution, async (req, res, next) => { try { - const { blockNumber, challenge, difficulty, signature, nonce } = SubmitPowCaptchaSolutionBody.parse( - req.body + const { blockNumber, challenge, difficulty, signature, nonce, verifiedTimeout } = + SubmitPowCaptchaSolutionBody.parse(req.body) + const verified = await tasks.verifyPowCaptchaSolution( + blockNumber, + challenge, + difficulty, + signature, + nonce, + verifiedTimeout ) - const verified = await tasks.verifyPowCaptchaSolution(blockNumber, challenge, difficulty, signature, nonce) const response: PowCaptchaSolutionResponse = { verified } return res.json(response) } catch (err) { diff --git a/packages/provider/src/tasks/tasks.ts b/packages/provider/src/tasks/tasks.ts index 7db5758c0e..764c514277 100644 --- a/packages/provider/src/tasks/tasks.ts +++ b/packages/provider/src/tasks/tasks.ts @@ -18,6 +18,7 @@ import { CaptchaSolution, CaptchaSolutionConfig, CaptchaWithProof, + DEFAULT_IMAGE_CAPTCHA_TIMEOUT, DappUserSolutionResult, DatasetBase, DatasetRaw, @@ -43,7 +44,7 @@ import { CaptchaStatus, Dapp, Provider, RandomProvider } from '@prosopo/captcha- import { ContractPromise } from '@polkadot/api-contract/promise' import { Database, UserCommitmentRecord } from '@prosopo/types-database' import { Logger, ProsopoContractError, ProsopoEnvError, getLogger } from '@prosopo/common' -import { ProsopoCaptchaContract, getCurrentBlockNumber, wrapQuery } from '@prosopo/contract' +import { ProsopoCaptchaContract, getCurrentBlockNumber, verifyRecency, wrapQuery } from '@prosopo/contract' import { ProviderEnvironment } from '@prosopo/types-env' import { SubmittableResult } from '@polkadot/api/submittable' import { at } from '@prosopo/util' @@ -196,24 +197,23 @@ export class Tasks { * @param {string} difficulty - how many leading zeroes the solution must have * @param {string} signature - proof that the Provider provided the challenge * @param {string} nonce - the string that the user has found that satisfies the PoW challenge + * @param {number} timeout - the time in milliseconds since the Provider was selected to provide the PoW captcha */ async verifyPowCaptchaSolution( blockNumber: number, challenge: string, difficulty: number, signature: string, - nonce: number + nonce: number, + timeout: number ): Promise { - const latestHeader = await this.contract.api.rpc.chain.getHeader() - const latestBlockNumber = latestHeader.number.toNumber() - - if (blockNumber < latestBlockNumber - 5) { + const recent = verifyRecency(this.contract.api, blockNumber, timeout) + if (!recent) { throw new ProsopoContractError('CONTRACT.INVALID_BLOCKHASH', { context: { - ERROR: 'Blockhash must be from within last 5 blocks', + ERROR: `Block in which the Provider was selected must be within the last ${timeout / 1000} seconds`, failedFuncName: this.verifyPowCaptchaSolution.name, blockNumber, - latestBlockNumber, }, }) } @@ -252,7 +252,7 @@ export class Tasks { return true } - async serverVerifyPowCaptchaSolution(dappAccount: string, challenge: string): Promise { + async serverVerifyPowCaptchaSolution(dappAccount: string, challenge: string, timeout: number): Promise { const challengeRecord = await this.db.getPowCaptchaRecordByChallenge(challenge) if (!challengeRecord) { throw new ProsopoEnvError('DATABASE.CAPTCHA_GET_FAILED', { @@ -276,23 +276,20 @@ export class Tasks { }) } - const latestHeader = await this.contract.api.rpc.chain.getHeader() - const latestBlockNumber = latestHeader.number.toNumber() - if (!blocknumber) { throw new ProsopoContractError('CONTRACT.INVALID_BLOCKHASH', { context: { - ERROR: 'Blockhash must be from within last 5 blocks', + ERROR: 'Block number not provided', failedFuncName: this.verifyPowCaptchaSolution.name, blocknumber, }, }) } - - if (latestBlockNumber > parseInt(blocknumber) + 5) { + const recent = verifyRecency(this.contract.api, parseInt(blocknumber), timeout) + if (!recent) { throw new ProsopoContractError('CONTRACT.INVALID_BLOCKHASH', { context: { - ERROR: 'Blockhash must be from within last 5 blocks', + ERROR: `Block in which the Provider was selected must be within the last ${timeout / 1000} seconds`, failedFuncName: this.verifyPowCaptchaSolution.name, blocknumber, }, @@ -536,7 +533,10 @@ export class Tasks { ) const currentTime = Date.now() - const timeLimit = captchas.map((captcha) => captcha.captcha.timeLimitMs || 30000).reduce((a, b) => a + b, 0) + const timeLimit = captchas + // if 2 captchas with 30s time limit, this will add to 1 minute (30s * 2) + .map((captcha) => captcha.captcha.timeLimitMs || DEFAULT_IMAGE_CAPTCHA_TIMEOUT) + .reduce((a, b) => a + b, 0) const deadlineTs = timeLimit + currentTime const currentBlockNumber = await getCurrentBlockNumber(this.contract.api) await this.db.storeDappUserPending(userAccount, requestHash, salt, deadlineTs, currentBlockNumber) diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index dc1d9d4741..4a908f9ff4 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -13,6 +13,7 @@ // limitations under the License. import { ApiPromise } from '@polkadot/api/promise/Api' import { + CaptchaTimeoutOutput, ContractAbi, NetworkConfig, NetworkNamesSchema, @@ -22,16 +23,13 @@ import { import { Keyring } from '@polkadot/keyring' import { KeyringPair } from '@polkadot/keyring/types' import { LogLevel, Logger, ProsopoEnvError, getLogger, trimProviderUrl } from '@prosopo/common' -import { ProsopoCaptchaContract, getBlockTimeMs, getCurrentBlockNumber, getZeroAddress } from '@prosopo/contract' +import { ProsopoCaptchaContract, getZeroAddress, verifyRecency } from '@prosopo/contract' import { ProviderApi } from '@prosopo/api' import { RandomProvider } from '@prosopo/captcha-contract/types-returns' import { WsProvider } from '@polkadot/rpc-provider/ws' import { ContractAbi as abiJson } from '@prosopo/captcha-contract/contract-info' import { get } from '@prosopo/util' -export const DEFAULT_MAX_VERIFIED_TIME_CACHED = 60 * 1000 -export const DEFAULT_MAX_VERIFIED_TIME_CONTRACT = 5 * 60 * 1000 - export class ProsopoServer { config: ProsopoServerConfigOutput contract: ProsopoCaptchaContract | undefined @@ -141,23 +139,6 @@ export class ProsopoServer { return undefined } - /** - * Verify the time since the blockNumber is equal to or less than the maxVerifiedTime. - * @param maxVerifiedTime - * @param blockNumber - */ - public async verifyRecency(blockNumber: number, maxVerifiedTime: number) { - const contractApi = await this.getContractApi() - // Get the current block number - const currentBlock = await getCurrentBlockNumber(contractApi.api) - // Calculate how many blocks have passed since the blockNumber - const blocksPassed = currentBlock - blockNumber - // Get the expected block time - const blockTime = getBlockTimeMs(contractApi.api) - // Check if the time since the last correct captcha is within the limit - return blockTime * blocksPassed <= maxVerifiedTime - } - /** * Verify the user with the contract. We check the contract to see if the user has completed a captcha in the * past. If they have, we check the time since the last correct captcha is within the maxVerifiedTime and we check @@ -165,7 +146,7 @@ export class ProsopoServer { * @param user * @param maxVerifiedTime */ - public async verifyContract(user: string, maxVerifiedTime = DEFAULT_MAX_VERIFIED_TIME_CONTRACT) { + public async verifyContract(user: string, maxVerifiedTime: number) { try { const contractApi = await this.getContractApi() this.logger.info('Provider URL not provided. Verifying with contract.') @@ -173,12 +154,22 @@ export class ProsopoServer { .unwrap() .unwrap() .before.valueOf() - const verifyRecency = await this.verifyRecency(correctCaptchaBlockNumber, maxVerifiedTime) + const recent = await verifyRecency( + (await this.getContractApi()).api, + correctCaptchaBlockNumber, + maxVerifiedTime + ) + if (!recent) { + this.logger.info('User has not completed a captcha recently') + return false + } const isHuman = (await contractApi.query.dappOperatorIsHumanUser(user, this.config.solutionThreshold)).value .unwrap() .unwrap() - return isHuman && verifyRecency + this.logger.info('isHuman', isHuman) + return isHuman } catch (error) { + this.logger.error(error) // if a user is not in the contract it errors, suppress this error and return false return false } @@ -191,38 +182,50 @@ export class ProsopoServer { * @param dapp * @param user * @param blockNumber + * @param timeouts * @param challenge * @param commitmentId - * @param maxVerifiedTime */ public async verifyProvider( providerUrl: string, dapp: string, user: string, blockNumber: number, + timeouts: CaptchaTimeoutOutput, challenge?: string, - commitmentId?: string, - maxVerifiedTime = DEFAULT_MAX_VERIFIED_TIME_CACHED + commitmentId?: string ) { this.logger.info('Verifying with provider.') const providerApi = await this.getProviderApi(providerUrl) if (challenge) { - const result = await providerApi.submitPowCaptchaVerify(challenge, dapp) + const result = await providerApi.submitPowCaptchaVerify(challenge, dapp, timeouts.pow.cachedTimeout) // We don't care about recency with PoW challenges as they are single use, so just return the verified result return result.verified } - const result = await providerApi.verifyDappUser(dapp, user, blockNumber, commitmentId, maxVerifiedTime) - const verifyRecency = await this.verifyRecency(result.blockNumber, maxVerifiedTime) - return result.verified && verifyRecency + const recent = await verifyRecency((await this.getContractApi()).api, blockNumber, timeouts.image.cachedTimeout) + + if (!recent) { + // bail early if the block is too old. This saves us calling the Provider. + return false + } + + const result = await providerApi.verifyDappUser( + dapp, + user, + blockNumber, + commitmentId, + timeouts.image.cachedTimeout + ) + + return result.verified } /** * * @param payload Info output by procaptcha on completion of the captcha process - * @param maxVerifiedTime Maximum time in milliseconds since the blockNumber * @returns */ - public async isVerified(payload: ProcaptchaOutput, maxVerifiedTime?: number): Promise { + public async isVerified(payload: ProcaptchaOutput): Promise { const { user, dapp, providerUrl, commitmentId, blockNumber, challenge } = payload if (providerUrl && blockNumber) { @@ -243,13 +246,13 @@ export class ProsopoServer { dapp, user, blockNumber, + this.config.timeouts, challenge, - commitmentId, - maxVerifiedTime + commitmentId ) } else { // If we don't have a providerURL, we verify with the contract - return await this.verifyContract(user, maxVerifiedTime) + return await this.verifyContract(user, this.config.timeouts.contract.maxVerifiedTime) } } diff --git a/packages/types/src/config/config.ts b/packages/types/src/config/config.ts index 200710af61..170da0ad9d 100644 --- a/packages/types/src/config/config.ts +++ b/packages/types/src/config/config.ts @@ -32,7 +32,24 @@ export const EnvironmentTypesSchema = zEnum(['development', 'staging', 'producti export type EnvironmentTypes = zInfer -const DEFAULT_MAX_VERIFIED_TIME_CACHED = 60 * 1000 +const ONE_MINUTE = 60 * 1000 +// The timeframe in which a user must complete an image captcha (1 minute) +export const DEFAULT_IMAGE_CAPTCHA_TIMEOUT = ONE_MINUTE +// The timeframe in which an image captcha solution remains valid on the page before timing out (2 minutes) +export const DEFAULT_IMAGE_CAPTCHA_SOLUTION_TIMEOUT = DEFAULT_IMAGE_CAPTCHA_TIMEOUT * 2 +// The timeframe in which an image captcha solution must be verified within (3 minutes) +export const DEFAULT_IMAGE_CAPTCHA_VERIFIED_TIMEOUT = DEFAULT_IMAGE_CAPTCHA_TIMEOUT * 3 +// The time in milliseconds that a cached, verified, image captcha solution is valid for (15 minutes) +export const DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED = DEFAULT_IMAGE_CAPTCHA_TIMEOUT * 15 +// The timeframe in which a pow captcha solution remains valid on the page before timing out (1 minute) +export const DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT = ONE_MINUTE +// The timeframe in which a pow captcha must be completed and verified (2 minutes) +export const DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT = DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT * 2 +// The time in milliseconds that a Provider cached, verified, pow captcha solution is valid for (3 minutes) +export const DEFAULT_POW_CAPTCHA_CACHED_TIMEOUT = DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT * 3 +// The time in milliseconds since the last correct captcha recorded in the contract (15 minutes), after which point, the +// user will be required to complete another captcha +export const DEFAULT_MAX_VERIFIED_TIME_CONTRACT = ONE_MINUTE * 15 export const DatabaseConfigSchema = record( EnvironmentTypesSchema, @@ -116,9 +133,56 @@ export const ProsopoClientConfigSchema = ProsopoBasicConfigSchema.merge( }) ) +const defaultImageCaptchaTimeouts = { + challengeTimeout: DEFAULT_IMAGE_CAPTCHA_TIMEOUT, + solutionTimeout: DEFAULT_IMAGE_CAPTCHA_SOLUTION_TIMEOUT, + verifiedTimeout: DEFAULT_IMAGE_CAPTCHA_VERIFIED_TIMEOUT, + cachedTimeout: DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED, +} + +const defaultPoWCaptchaTimeouts = { + challengeTimeout: DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT, + solutionTimeout: DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT, + cachedTimeout: DEFAULT_POW_CAPTCHA_CACHED_TIMEOUT, +} + +const defaultContractCaptchaTimeouts = { + maxVerifiedTime: DEFAULT_MAX_VERIFIED_TIME_CONTRACT, +} + +const defaultCaptchaTimeouts = { + image: defaultImageCaptchaTimeouts, + pow: defaultPoWCaptchaTimeouts, + contract: defaultContractCaptchaTimeouts, +} + +export const CaptchaTimeoutSchema = object({ + image: object({ + // Set this to a default value for the frontend + challengeTimeout: number().positive().optional().default(DEFAULT_IMAGE_CAPTCHA_TIMEOUT), + // Set this to a default value for the frontend + solutionTimeout: number().positive().optional().default(DEFAULT_IMAGE_CAPTCHA_SOLUTION_TIMEOUT), + verifiedTimeout: number().positive().optional().default(DEFAULT_IMAGE_CAPTCHA_VERIFIED_TIMEOUT), + cachedTimeout: number().positive().optional().default(DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED), + }).default(defaultImageCaptchaTimeouts), + pow: object({ + verifiedTimeout: number().positive().optional().default(DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT), + solutionTimeout: number().positive().optional().default(DEFAULT_POW_CAPTCHA_SOLUTION_TIMEOUT), + cachedTimeout: number().positive().optional().default(DEFAULT_POW_CAPTCHA_CACHED_TIMEOUT), + }).default(defaultPoWCaptchaTimeouts), + contract: object({ + maxVerifiedTime: number().positive().optional().default(DEFAULT_MAX_VERIFIED_TIME_CONTRACT), + }).default(defaultContractCaptchaTimeouts), +}).default(defaultCaptchaTimeouts) + +export type CaptchaTimeoutInput = input + +export type CaptchaTimeoutOutput = output + export const ProsopoServerConfigSchema = ProsopoClientConfigSchema.merge( object({ serverUrl: string().url().optional(), + timeouts: CaptchaTimeoutSchema.optional().default(defaultCaptchaTimeouts), }) ) @@ -147,7 +211,7 @@ export const ProcaptchaConfigSchema = ProsopoClientConfigSchema.and( object({ accountCreator: AccountCreatorConfigSchema.optional(), theme: ThemeType.optional(), - challengeValidLength: number().positive().optional().default(DEFAULT_MAX_VERIFIED_TIME_CACHED), + captchas: CaptchaTimeoutSchema.optional().default(defaultCaptchaTimeouts), }) ) diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 9e45ca1c82..359eba3463 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. import { CaptchaSolutionSchema, CaptchaWithProof } from '../datasets/index.js' +import { DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED, DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT } from '../config/index.js' import { Hash, Provider } from '@prosopo/captcha-contract/types-returns' import { array, number, object, string, infer as zInfer } from 'zod' @@ -47,6 +48,7 @@ export enum ApiParams { proof = 'proof', providerUrl = 'providerUrl', procaptchaResponse = 'procaptcha-response', + verifiedTimeout = 'verifiedTimeout', maxVerifiedTime = 'maxVerifiedTime', verified = 'verified', status = 'status', @@ -99,7 +101,7 @@ export const VerifySolutionBody = object({ [ApiParams.user]: string(), [ApiParams.blockNumber]: number(), [ApiParams.commitmentId]: string().optional(), - [ApiParams.maxVerifiedTime]: number().optional(), + [ApiParams.maxVerifiedTime]: number().optional().default(DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED), }) export type VerifySolutionBodyType = zInfer @@ -143,9 +145,16 @@ export interface PowCaptchaSolutionResponse { [ApiParams.verified]: boolean } +/** + * Request body for the server to verify a PoW captcha solution + * @param {string} challenge - The challenge string + * @param {string} dapp - The dapp account (site key) + * @param {number} timeout - The maximum time in milliseconds since the Provider was selected at `blockNumber` + */ export const ServerPowCaptchaVerifyRequestBody = object({ [ApiParams.challenge]: string(), [ApiParams.dapp]: string(), + [ApiParams.verifiedTimeout]: number().optional().default(DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT), }) export const GetPowCaptchaChallengeRequestBody = object({ @@ -165,6 +174,7 @@ export const SubmitPowCaptchaSolutionBody = object({ [ApiParams.user]: string(), [ApiParams.dapp]: string(), [ApiParams.nonce]: number(), + [ApiParams.verifiedTimeout]: number().optional().default(DEFAULT_POW_CAPTCHA_VERIFIED_TIMEOUT), }) export type SubmitPowCaptchaSolutionBodyType = zInfer