Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/fingerprint-summary-every-captcha.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@prosopo/types": minor
"@prosopo/types-database": minor
"@prosopo/database": minor
"@prosopo/api": minor
"@prosopo/provider": minor
"@prosopo/procaptcha-frictionless": minor
---

Record the fingerprint summary, and with it the device bucket, for every captcha type rather than only PoW.

The proof of fingerprint was only built by the PoW widget and only read when a PoW solution was submitted, so sessions served a puzzle or an image never had a bucket. About half of puzzle drags could only be given one by finding a PoW the same account had solved.

The widget now sends the proof with the frictionless request, best-effort and capped at a second so it never delays the captcha. The provider verifies and summarises it once, stores the summary on the session, and the PoW, puzzle and image records copy it from there. Escalation sessions inherit it. A proof sent with a PoW solution still replaces the one from the frictionless request.

The summary is only recorded at the frictionless step, not passed to the routing machine there, so existing `deviceBucket` counter rules keep firing where they did before.
4 changes: 4 additions & 0 deletions packages/api/src/api/ProviderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,7 @@ export default class ProviderApi
clientSessionId?: string,
refreshOf?: string,
switchToImage?: boolean,
fingerprintProof?: string,
): Promise<GetFrictionlessCaptchaResponse> {
const body: GetFrictionlessCaptchaChallengeRequestBodyOutput = {
[ApiParams.dapp]: dapp,
Expand All @@ -645,6 +646,9 @@ export default class ProviderApi
...(refreshOf && { [ApiParams.refreshOf]: refreshOf }),
...(refreshOf &&
switchToImage && { [ApiParams.switchToImage]: switchToImage }),
...(fingerprintProof && {
[ApiParams.fingerprintProof]: fingerprintProof,
}),
};
const { data, headers } = await this.postWithHeaders<
GetFrictionlessCaptchaResponse,
Expand Down
5 changes: 5 additions & 0 deletions packages/database/src/databases/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
type DecisionMachineArtifact,
DecisionMachineKind,
type DecisionMachineScope,
type FingerprintSummaryStored,
type Hash,
type IPInfoResponse,
type IconOrderCaptchaStored,
Expand Down Expand Up @@ -901,6 +902,7 @@ export class ProviderDatabase
userSubmitted = false,
userSignature?: string,
ipInfo?: IPInfoResponse,
fingerprintSummary?: FingerprintSummaryStored,
): Promise<void> {
const tables = this.getTables();

Expand All @@ -922,6 +924,7 @@ export class ProviderDatabase
pendingStage: true,
sessionId,
ipInfo,
...(fingerprintSummary && { fingerprintSummary }),
};

try {
Expand Down Expand Up @@ -1354,6 +1357,7 @@ export class ProviderDatabase
ja4: string,
sessionId?: string,
ipInfo?: IPInfoResponse,
fingerprintSummary?: FingerprintSummaryStored,
): Promise<void> {
await this.storeInteractiveCaptchaRecord(
this.interactiveCollection(CaptchaType.puzzle),
Expand All @@ -1373,6 +1377,7 @@ export class ProviderDatabase
targetY,
originX,
originY,
...(fingerprintSummary && { fingerprintSummary }),
},
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/procaptcha-frictionless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"dependencies": {
"@prosopo/api": "workspace:*",
"@prosopo/common": "workspace:*",
"@prosopo/fingerprint": "workspace:*",
"@prosopo/locale": "workspace:*",
"@prosopo/procaptcha-audio": "workspace:*",
"@prosopo/procaptcha-common": "workspace:*",
Expand Down
27 changes: 27 additions & 0 deletions packages/procaptcha-frictionless/src/customDetectBot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@

import { ProviderApi } from "@prosopo/api";
import { ProsopoError } from "@prosopo/common";
import {
FINGERPRINT_DISCLOSURE_KEYS,
encodeFingerprintProof,
getFingerprintProof,
} from "@prosopo/fingerprint";
import { widgetLanguageHeaders } from "@prosopo/locale";
import {
ExtensionLoader,
Expand Down Expand Up @@ -146,6 +151,24 @@ export const withTimeout = async <T>(
}
};

// Sent with the frictionless request so the provider can record the device
// bucket for every session, whichever captcha type it is then served.
// Best-effort and capped: a proof that is slow or fails to build is left off
// rather than delaying the captcha.
const FINGERPRINT_PROOF_TIMEOUT_MS = 1000;

const fingerprintProofIfReady = async (): Promise<string | undefined> => {
try {
const proof = await withTimeout(
getFingerprintProof(FINGERPRINT_DISCLOSURE_KEYS),
FINGERPRINT_PROOF_TIMEOUT_MS,
);
return encodeFingerprintProof(proof);
} catch {
return undefined;
}
};

const customDetectBot: BotDetectionFunction = async (
config: ProcaptchaClientConfigOutput,
container: HTMLElement | undefined,
Expand Down Expand Up @@ -286,6 +309,7 @@ const customDetectBot: BotDetectionFunction = async (
const userAccount = await ext.getAccount(config);
const { currentUrl: fallbackUrl, iframeUrl: fallbackIframeUrl } =
getCurrentPageUrls();
const fallbackFingerprintProof = await fingerprintProofIfReady();
const captcha = await withTimeout(
providerApi.getFrictionlessCaptcha(
undefined,
Expand All @@ -300,6 +324,7 @@ const customDetectBot: BotDetectionFunction = async (
clientSessionId,
refreshOf,
switchToImage,
fallbackFingerprintProof,
),
10000,
);
Expand Down Expand Up @@ -347,6 +372,7 @@ const customDetectBot: BotDetectionFunction = async (
// burns. Readings still attach on the challenge GET and on solution
// submit (first-hop-wins server-side).
const { currentUrl, iframeUrl } = getCurrentPageUrls();
const fingerprintProof = await fingerprintProofIfReady();
const captchaPromise = providerApi.getFrictionlessCaptcha(
detectionResult.token,
detectionResult.encryptHeadHash,
Expand All @@ -360,6 +386,7 @@ const customDetectBot: BotDetectionFunction = async (
clientSessionId,
refreshOf,
switchToImage,
fingerprintProof,
);
if (detectionResult.getSimdReadings) {
// Fire-and-forget: triggers the memoised prefetch inside the catcher
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// Copyright 2021-2026 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
* The proof of fingerprint used to go out only with a PoW solution, so a
* session served a puzzle or an image never recorded its device bucket. It is
* now sent with the frictionless request, best-effort: a proof that fails or
* is slow is left off rather than holding the captcha up.
*/

import type { AssignDetectorBundleResponse } from "@prosopo/types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
getFrictionlessCaptcha: vi.fn(),
getProcaptchaRandomActiveProvider: vi.fn(),
assignDetectorBundle: vi.fn(),
detectorLoaderFromScript: vi.fn(),
detect: vi.fn(),
getFingerprintProof: vi.fn(),
encodeFingerprintProof: vi.fn(),
}));

vi.mock("@prosopo/api", () => ({
ProviderApi: vi.fn(function () {
return {
getFrictionlessCaptcha: mocks.getFrictionlessCaptcha,
assignDetectorBundle: mocks.assignDetectorBundle,
};
}),
}));

vi.mock("@prosopo/procaptcha-common", () => ({
ExtensionLoader: vi.fn(async () => {
return class FakeExtension {
getAccount() {
return Promise.resolve({
account: { address: "5FakeUserAccountAddress" },
});
}
};
}),
getProcaptchaRandomActiveProvider: mocks.getProcaptchaRandomActiveProvider,
pickIpMode: vi.fn(() => undefined),
resolveClientSessionId: vi.fn(
(clientSessionId?: string): string | undefined => clientSessionId,
),
}));

vi.mock("@prosopo/fingerprint", () => ({
FINGERPRINT_DISCLOSURE_KEYS: ["timezone", "deviceBucket"],
getFingerprintProof: mocks.getFingerprintProof,
encodeFingerprintProof: mocks.encodeFingerprintProof,
}));

vi.mock("../detectorLoader.js", () => ({
DetectorLoaderFromScript: mocks.detectorLoaderFromScript,
}));

import customDetectBot from "../customDetectBot.js";

const baseConfig = {
account: { address: "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP" },
defaultEnvironment: "staging" as const,
web2: true,
mode: "visible" as const,
} as unknown as Parameters<typeof customDetectBot>[0];

const assignResponse: AssignDetectorBundleResponse = {
useProviderBundle: true,
detectorSessionId: "det-5cad2419-43c0-41b7-a9b8-0117767624bc",
detectorScript: "SELF_CONTAINED_ESM",
status: "ok",
};

const ENCODED_PROOF = "ENCODED_PROOF";

/** The proof argument of the frictionless request. */
const postedProof = (): string | undefined =>
mocks.getFrictionlessCaptcha.mock.calls[0]?.[12] as string | undefined;

const runDetection = async (): Promise<void> => {
const pending = customDetectBot(baseConfig, undefined, () => undefined);
await vi.advanceTimersByTimeAsync(5000);
await pending;
};

beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mocks.getProcaptchaRandomActiveProvider.mockResolvedValue({
providerAccount: "dns-routed",
provider: { url: "https://staging-pronode3.prosopo.io" },
});
mocks.detect.mockResolvedValue({
token: "ENCRYPTED_TOKEN",
encryptHeadHash: "ENCRYPTED_HEAD_HASH",
userAccount: { account: { address: "5FakeUserAccountAddress" } },
shadowDomCleanup: () => undefined,
});
mocks.detectorLoaderFromScript.mockResolvedValue(mocks.detect);
mocks.assignDetectorBundle.mockResolvedValue(assignResponse);
mocks.getFrictionlessCaptcha.mockResolvedValue({
captchaType: "puzzle",
sessionId: "SID",
status: "ok",
});
mocks.getFingerprintProof.mockResolvedValue({ root: "0xroot" });
mocks.encodeFingerprintProof.mockReturnValue(ENCODED_PROOF);
});

afterEach(() => {
vi.useRealTimers();
});

describe("proof of fingerprint on the frictionless request", () => {
it("is sent when the detector ran", async () => {
await runDetection();

expect(mocks.getFingerprintProof).toHaveBeenCalledWith([
"timezone",
"deviceBucket",
]);
expect(postedProof()).toBe(ENCODED_PROOF);
});

it("is sent when the provider had no detector to assign", async () => {
mocks.assignDetectorBundle.mockResolvedValue({
useProviderBundle: false,
status: "ok",
} satisfies AssignDetectorBundleResponse);

await runDetection();

expect(mocks.detectorLoaderFromScript).not.toHaveBeenCalled();
expect(postedProof()).toBe(ENCODED_PROOF);
});

it("is left off, and the request still sent, when the proof fails", async () => {
mocks.getFingerprintProof.mockRejectedValue(new Error("no components"));

await runDetection();

expect(mocks.getFrictionlessCaptcha).toHaveBeenCalledTimes(1);
expect(postedProof()).toBeUndefined();
});

it("is left off when building it takes longer than a second", async () => {
mocks.getFingerprintProof.mockReturnValue(new Promise(() => undefined));

await runDetection();

expect(mocks.getFrictionlessCaptcha).toHaveBeenCalledTimes(1);
expect(postedProof()).toBeUndefined();
});
});
3 changes: 3 additions & 0 deletions packages/procaptcha-frictionless/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
{
"path": "../common"
},
{
"path": "../fingerprint"
},
{
"path": "../locale"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.

import { ProsopoError } from "@prosopo/common";
import { summariseFingerprintProof } from "@prosopo/fingerprint/summary";
import {
ApiParams,
CaptchaType,
Expand Down Expand Up @@ -116,8 +117,15 @@ export default (
clientSessionId,
refreshOf,
switchToImage,
fingerprintProof,
} = GetFrictionlessCaptchaChallengeRequestBody.parse(req.body);

// Recorded on the session and copied onto whichever captcha record it
// leads to, so the device bucket exists for image and puzzle solves
// too. Not handed to the routing machine here: counter rules naming
// `deviceBucket` are written for the PoW-submit stage.
const fingerprintSummary = summariseFingerprintProof(fingerprintProof);

// Re-sanitise whatever the client reported: keep only scheme + host
// + path and drop the query string, fragment and any embedded
// credentials so we never persist secrets carried in the page URL.
Expand Down Expand Up @@ -934,6 +942,7 @@ export default (
...(decodedSimdReadings && { simdReadings: decodedSimdReadings }),
...(d !== undefined && { d }),
...(clientSessionId && { clientMetaData: { clientSessionId } }),
...(fingerprintSummary && { fingerprintSummary }),
...refresh?.lineage,
...(req.tcpToChelloUs !== undefined && {
tcpToChelloUs: req.tcpToChelloUs,
Expand Down
3 changes: 3 additions & 0 deletions packages/provider/src/api/captcha/getPoWCaptchaChallenge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ export default (
// individual flags off this object instead of separate
// flat fields.
req.ipInfo,
// From the proof sent at frictionless time. A proof submitted
// with the solution replaces it.
sessionRecord?.fingerprintSummary,
);

const padBytes = trafficVerdict.padBytes;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export const issuePuzzleChallenge = async ({
provenance.ja4,
provenance.sessionId,
provenance.ipInfo,
sessionRecord?.fingerprintSummary,
);

// Render AFTER the record is stored: the target must be durable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,10 @@ export const buildEscalation = async (
// The escalated session is the same render as the origin, so it answers
// to the same session id the verify call will correlate against.
clientMetaData: originSession.clientMetaData,
// Same device, so the same bucket. A proof submitted with this PoW is
// newer than the one sent at frictionless time, so it wins.
fingerprintSummary:
powRecord.fingerprintSummary ?? originSession.fingerprintSummary,
});

// Record the origin → escalation sessionId mapping so a /captcha/*
Expand Down
Loading
Loading