diff --git a/.changeset/fingerprint-summary-every-captcha.md b/.changeset/fingerprint-summary-every-captcha.md new file mode 100644 index 0000000000..cd1678c8d5 --- /dev/null +++ b/.changeset/fingerprint-summary-every-captcha.md @@ -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. diff --git a/packages/api/src/api/ProviderApi.ts b/packages/api/src/api/ProviderApi.ts index 1ec9d995c2..72a199a8f4 100644 --- a/packages/api/src/api/ProviderApi.ts +++ b/packages/api/src/api/ProviderApi.ts @@ -626,6 +626,7 @@ export default class ProviderApi clientSessionId?: string, refreshOf?: string, switchToImage?: boolean, + fingerprintProof?: string, ): Promise { const body: GetFrictionlessCaptchaChallengeRequestBodyOutput = { [ApiParams.dapp]: dapp, @@ -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, diff --git a/packages/database/src/databases/provider.ts b/packages/database/src/databases/provider.ts index c8c5e47172..3093362233 100644 --- a/packages/database/src/databases/provider.ts +++ b/packages/database/src/databases/provider.ts @@ -37,6 +37,7 @@ import { type DecisionMachineArtifact, DecisionMachineKind, type DecisionMachineScope, + type FingerprintSummaryStored, type Hash, type IPInfoResponse, type IconOrderCaptchaStored, @@ -901,6 +902,7 @@ export class ProviderDatabase userSubmitted = false, userSignature?: string, ipInfo?: IPInfoResponse, + fingerprintSummary?: FingerprintSummaryStored, ): Promise { const tables = this.getTables(); @@ -922,6 +924,7 @@ export class ProviderDatabase pendingStage: true, sessionId, ipInfo, + ...(fingerprintSummary && { fingerprintSummary }), }; try { @@ -1354,6 +1357,7 @@ export class ProviderDatabase ja4: string, sessionId?: string, ipInfo?: IPInfoResponse, + fingerprintSummary?: FingerprintSummaryStored, ): Promise { await this.storeInteractiveCaptchaRecord( this.interactiveCollection(CaptchaType.puzzle), @@ -1373,6 +1377,7 @@ export class ProviderDatabase targetY, originX, originY, + ...(fingerprintSummary && { fingerprintSummary }), }, ); } diff --git a/packages/procaptcha-frictionless/package.json b/packages/procaptcha-frictionless/package.json index 2d61df736f..c73c1429c1 100644 --- a/packages/procaptcha-frictionless/package.json +++ b/packages/procaptcha-frictionless/package.json @@ -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:*", diff --git a/packages/procaptcha-frictionless/src/customDetectBot.ts b/packages/procaptcha-frictionless/src/customDetectBot.ts index 1100ec04d8..3a4699b4a3 100644 --- a/packages/procaptcha-frictionless/src/customDetectBot.ts +++ b/packages/procaptcha-frictionless/src/customDetectBot.ts @@ -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, @@ -146,6 +151,24 @@ export const withTimeout = async ( } }; +// 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 => { + 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, @@ -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, @@ -300,6 +324,7 @@ const customDetectBot: BotDetectionFunction = async ( clientSessionId, refreshOf, switchToImage, + fallbackFingerprintProof, ), 10000, ); @@ -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, @@ -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 diff --git a/packages/procaptcha-frictionless/src/tests/customDetectBotFingerprint.test.ts b/packages/procaptcha-frictionless/src/tests/customDetectBotFingerprint.test.ts new file mode 100644 index 0000000000..48f6ffafac --- /dev/null +++ b/packages/procaptcha-frictionless/src/tests/customDetectBotFingerprint.test.ts @@ -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[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 => { + 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(); + }); +}); diff --git a/packages/procaptcha-frictionless/tsconfig.json b/packages/procaptcha-frictionless/tsconfig.json index 4902690693..499a4e140e 100644 --- a/packages/procaptcha-frictionless/tsconfig.json +++ b/packages/procaptcha-frictionless/tsconfig.json @@ -25,6 +25,9 @@ { "path": "../common" }, + { + "path": "../fingerprint" + }, { "path": "../locale" }, diff --git a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts index 97a0c8e65c..bf2e24c3e7 100644 --- a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts +++ b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts @@ -13,6 +13,7 @@ // limitations under the License. import { ProsopoError } from "@prosopo/common"; +import { summariseFingerprintProof } from "@prosopo/fingerprint/summary"; import { ApiParams, CaptchaType, @@ -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. @@ -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, diff --git a/packages/provider/src/api/captcha/getPoWCaptchaChallenge.ts b/packages/provider/src/api/captcha/getPoWCaptchaChallenge.ts index 4f4d1f3424..8391bdd8d2 100644 --- a/packages/provider/src/api/captcha/getPoWCaptchaChallenge.ts +++ b/packages/provider/src/api/captcha/getPoWCaptchaChallenge.ts @@ -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; diff --git a/packages/provider/src/api/captcha/getPuzzleCaptchaChallenge.ts b/packages/provider/src/api/captcha/getPuzzleCaptchaChallenge.ts index 0f198cb430..4cd14e5cd5 100644 --- a/packages/provider/src/api/captcha/getPuzzleCaptchaChallenge.ts +++ b/packages/provider/src/api/captcha/getPuzzleCaptchaChallenge.ts @@ -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 diff --git a/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts b/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts index 20a52e8297..6c190ffe81 100644 --- a/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts +++ b/packages/provider/src/api/captcha/submitPoWCaptchaSolution.ts @@ -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/* diff --git a/packages/provider/src/tasks/captchaManager.ts b/packages/provider/src/tasks/captchaManager.ts index 7b21232bd7..854d2bb985 100644 --- a/packages/provider/src/tasks/captchaManager.ts +++ b/packages/provider/src/tasks/captchaManager.ts @@ -203,8 +203,8 @@ export class CaptchaManager { * Read a session and, if it's an escalation missing one of the * inherently-origin-populated fields, walk to `originSessionId` and fill * the gap. Intended for the decision-machine input read path only — - * `simdReadings`, `dnsEvent` and the detector bag are - * populated on the origin session (SIMD via pow-submit's fire-and-forget + * `simdReadings`, `dnsEvent`, `fingerprintSummary` and the detector bag + * are populated on the origin session (SIMD via pow-submit's fire-and-forget * attach; DNS via the sidecar's origin-TLS-scoped patch) and don't * automatically end up on the escalation record because * `buildEscalation` reads the origin from Mongo before the async writes @@ -238,8 +238,9 @@ export class CaptchaManager { // the escalation copied it. The escalation's own keys always win. const escalationData = session.d ?? {}; const needsData = session.d === undefined; + const needsFingerprint = session.fingerprintSummary === undefined; - if (!needsSimd && !needsDns && !needsData) { + if (!needsSimd && !needsDns && !needsData && !needsFingerprint) { return session; } @@ -257,6 +258,10 @@ export class CaptchaManager { origin.simdReadings && { simdReadings: origin.simdReadings }), ...(needsDns && origin.dnsEvent && { dnsEvent: origin.dnsEvent }), ...(mergedData !== undefined && { d: mergedData }), + ...(needsFingerprint && + origin.fingerprintSummary && { + fingerprintSummary: origin.fingerprintSummary, + }), }; } diff --git a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts index a67590f866..5fedd9d90f 100644 --- a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts +++ b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts @@ -211,6 +211,7 @@ export class FrictionlessManager extends CaptchaManager { refreshOf: params.refreshOf, refreshCount: params.refreshCount, refreshedAfterMs: params.refreshedAfterMs, + fingerprintSummary: params.fingerprintSummary, }; } @@ -276,6 +277,7 @@ export class FrictionlessManager extends CaptchaManager { isProtect, matchedRule, clientMetaData, + fingerprintSummary, webView = false, iFrame = false, decryptedHeadHash = "", @@ -331,6 +333,7 @@ export class FrictionlessManager extends CaptchaManager { // Stamped at issuance, not only mirrored up at solve time, so a // session that is never solved still correlates back to the render. ...(clientMetaData && { clientMetaData }), + ...(fingerprintSummary && { fingerprintSummary }), }; await this.db.storeSessionRecord(sessionRecord); diff --git a/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts b/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts index 429d164c8e..4de641ea50 100644 --- a/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts +++ b/packages/provider/src/tasks/imgCaptcha/imgCaptchaTasks.ts @@ -324,10 +324,17 @@ export class ImgCaptchaManager extends CaptchaManager { // (promoted onto the session record at frictionless time) is resolved once // and both payloads decode together. img submit may attach to multiple // sessions, all sharing this bundle. - const decodedPayloads = await this.decodeSubmissionPayloads( - pendingRecord?.sessionId, - { behavioural: behavioralData, simd: simdReadings }, - ); + // The session is read alongside the decode rather than after it: its + // fingerprint summary is copied onto the commitment below. + const [decodedPayloads, sessionRecord] = await Promise.all([ + this.decodeSubmissionPayloads(pendingRecord?.sessionId, { + behavioural: behavioralData, + simd: simdReadings, + }), + pendingRecord?.sessionId + ? this.db.getSessionRecordBySessionId(pendingRecord.sessionId) + : undefined, + ]); const decodedSimdReadings = decodedPayloads.simd; const pushSimdAttachIfAny = ( sessionId: string, @@ -474,6 +481,9 @@ export class ImgCaptchaManager extends CaptchaManager { clientMetaData: storedClientMetaData, }), ...(inputMethodsDeclared && inputMethods && { inputMethods }), + ...(sessionRecord?.fingerprintSummary && { + fingerprintSummary: sessionRecord.fingerprintSummary, + }), }; await this.db.storeUserImageCaptchaSolution(receivedCaptchas, commit); diff --git a/packages/provider/src/tests/unit/api/captcha/puzzleTouchFloors.unit.test.ts b/packages/provider/src/tests/unit/api/captcha/puzzleTouchFloors.unit.test.ts index 49bed60924..ccfe5bb380 100644 --- a/packages/provider/src/tests/unit/api/captcha/puzzleTouchFloors.unit.test.ts +++ b/packages/provider/src/tests/unit/api/captcha/puzzleTouchFloors.unit.test.ts @@ -23,7 +23,7 @@ import { TOUCH_MIN_TOLERANCE_PX, } from "@prosopo/captcha-severity"; import { DEFAULT_GEOMETRY } from "@prosopo/puzzle-assets"; -import type { IPuzzleSettings } from "@prosopo/types"; +import type { FingerprintSummaryStored, IPuzzleSettings } from "@prosopo/types"; import type { ClientRecord, ProjectedSession } from "@prosopo/types-database"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -77,6 +77,7 @@ const buildContext = (overrides: { sessionPuzzleTolerance?: number; trafficVerdict?: RequestTimeTrafficVerdict; carrierNetwork?: boolean; + sessionFingerprintSummary?: FingerprintSummaryStored; }): ChallengeIssueContext => ({ tasks: buildTasks(), user: "user", @@ -95,6 +96,9 @@ const buildContext = (overrides: { puzzleTolerance: overrides.sessionPuzzleTolerance, }), ...(overrides.sessionPuzzle && { puzzle: overrides.sessionPuzzle }), + ...(overrides.sessionFingerprintSummary && { + fingerprintSummary: overrides.sessionFingerprintSummary, + }), } as unknown as ProjectedSession, trafficVerdict: overrides.trafficVerdict ?? { kind: "pass" }, provenance: { @@ -289,3 +293,54 @@ describe("the floors need corroboration", () => { expect(servedPieceSize()).toBe(TOUCH_MIN_PIECE_PX); }); }); + +describe("the session's fingerprint summary at issuance", () => { + beforeEach(() => { + vi.clearAllMocks(); + getPuzzleCaptchaChallenge = vi.fn().mockResolvedValue({ + challenge: "challenge", + requestedAtTimestamp: 0, + targetX: 10, + targetY: 10, + originX: 0, + originY: 0, + tolerance: 12, + providerSignature: "sig", + }); + renderPuzzleImagesMock.mockResolvedValue({ + background: "bg", + piece: "piece", + pieceSize: 60, + backgroundSeed: "seed", + renderSeed: "seed", + }); + }); + + /** The summary argument storePuzzleCaptchaRecord was given. */ + const storedSummary = (ctx: ChallengeIssueContext): unknown => + vi.mocked(ctx.tasks.db.storePuzzleCaptchaRecord).mock.calls[0]?.[13]; + + it("is stored on the puzzle record", async () => { + const fingerprintSummary = { + root: "0xroot", + groupHash: "0xgroup", + bucketId: "9f1c3a0e7b2d4c6a8e0f1a2b3c4d5e6f", + }; + const ctx = buildContext({ + userAgent: WINDOWS_CHROME_UA, + sessionFingerprintSummary: fingerprintSummary, + }); + + await issuePuzzleChallenge(ctx); + + expect(storedSummary(ctx)).toStrictEqual(fingerprintSummary); + }); + + it("is left off when the session has none", async () => { + const ctx = buildContext({ userAgent: WINDOWS_CHROME_UA }); + + await issuePuzzleChallenge(ctx); + + expect(storedSummary(ctx)).toBeUndefined(); + }); +}); diff --git a/packages/provider/src/tests/unit/api/captcha/submitPoWCaptchaSolution.escalation.unit.test.ts b/packages/provider/src/tests/unit/api/captcha/submitPoWCaptchaSolution.escalation.unit.test.ts index 885757793f..a61a934fb8 100644 --- a/packages/provider/src/tests/unit/api/captcha/submitPoWCaptchaSolution.escalation.unit.test.ts +++ b/packages/provider/src/tests/unit/api/captcha/submitPoWCaptchaSolution.escalation.unit.test.ts @@ -15,6 +15,7 @@ import { CaptchaType, type DetectorData, + type FingerprintSummaryStored, type Session, resolveAllowedCaptchaTypes, } from "@prosopo/types"; @@ -93,6 +94,47 @@ describe("submitPoWCaptchaSolution.buildEscalation", () => { env = makeTasks(); }); + describe("fingerprint summary", () => { + const submitted: FingerprintSummaryStored = { + root: "0xsubmitted", + groupHash: "0xgroup", + bucketId: "a".repeat(32), + }; + const atFrictionless: FingerprintSummaryStored = { + root: "0xfrictionless", + groupHash: "0xgroup", + bucketId: "b".repeat(32), + }; + + const escalate = async (powSummary?: FingerprintSummaryStored) => { + env.spies.getPowCaptchaRecordByChallenge.mockResolvedValue({ + sessionId: "origin-id", + dappAccount: "dapp", + ...(powSummary && { fingerprintSummary: powSummary }), + }); + env.spies.getSessionRecordBySessionId.mockResolvedValue({ + ...makeOriginSession(), + fingerprintSummary: atFrictionless, + }); + await buildEscalation( + env.tasks, + { verified: true, routingOutput: { captchaType: CaptchaType.puzzle } }, + "challenge", + ); + const input: CreateSessionInput = + env.spies.createSession.mock.calls[0]?.[0]; + return input.fingerprintSummary; + }; + + it("carries the summary from the proof submitted with the PoW", async () => { + expect(await escalate(submitted)).toStrictEqual(submitted); + }); + + it("falls back to the summary recorded at frictionless time", async () => { + expect(await escalate()).toStrictEqual(atFrictionless); + }); + }); + it("returns undefined when no routing output was supplied (no escalation)", async () => { const out = await buildEscalation( env.tasks, diff --git a/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts b/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts index 9227dbdb56..ab1c6fed37 100644 --- a/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts +++ b/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts @@ -15,6 +15,10 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { + buildFingerprintProofFromComponents, + encodeFingerprintProof, +} from "@prosopo/fingerprint"; import type { I18nLookup } from "@prosopo/locale"; import { CaptchaType, @@ -635,6 +639,58 @@ describe("getFrictionlessCaptchaChallenge - context selection", () => { ); }); + describe("proof of fingerprint", () => { + const BUCKET = "9f1c3a0e7b2d4c6a8e0f1a2b3c4d5e6f"; + const proof = encodeFingerprintProof( + buildFingerprintProofFromComponents({ + timezone: { value: "Europe/London", duration: 1 }, + colorDepth: { value: 24, duration: 1 }, + deviceBucket: { value: BUCKET, duration: 1 }, + }), + ); + + const send = async (fingerprintProof?: string) => { + tasksInstance.db.getClientRecord.mockResolvedValue({ + account: "siteFp", + settings: { frictionlessThreshold: 0.5, disallowWebView: false }, + }); + tasksInstance.frictionlessManager.decryptPayload.mockResolvedValue( + payload(false), + ); + const { req, res, next } = buildReqRes({ + token: "tFp", + headHash: "hhFp", + dapp: "siteFp", + user: "u", + ...(fingerprintProof !== undefined && { fingerprintProof }), + }); + // biome-ignore lint/suspicious/noExplicitAny: mock request + await handler(req as any, res as any, next); + return tasksInstance.frictionlessManager.setSessionParams.mock + .calls[0]?.[0]; + }; + + it("puts the verified summary on the session", async () => { + const params = await send(proof); + + expect(params?.fingerprintSummary?.bucketId).toBe(BUCKET); + }); + + it("puts nothing on the session for a proof that does not verify", async () => { + const params = await send("not-a-proof"); + + expect(params).toBeDefined(); + expect(params).not.toHaveProperty("fingerprintSummary"); + }); + + it("puts nothing on the session when no proof was sent", async () => { + const params = await send(); + + expect(params).toBeDefined(); + expect(params).not.toHaveProperty("fingerprintSummary"); + }); + }); + it("reuses the cached session when no access policy conflicts with its captchaType", async () => { const clientRecord = { account: "siteDedupOk", diff --git a/packages/provider/src/tests/unit/tasks/captchaManager.unit.test.ts b/packages/provider/src/tests/unit/tasks/captchaManager.unit.test.ts index d73cdc7976..ab5e171c0e 100644 --- a/packages/provider/src/tests/unit/tasks/captchaManager.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/captchaManager.unit.test.ts @@ -15,6 +15,7 @@ import type { RedisWriteQueue } from "@prosopo/database"; import { type Logger, getLogger } from "@prosopo/logger"; import { + type FingerprintSummaryStored, IpAddressType, type KeyringPair, type Session, @@ -133,6 +134,17 @@ describe("CaptchaManager", () => { // fields that live on the origin are surfaced on the escalation record // without duplicating them at write time. describe("getSessionRecordWithOriginFallback", () => { + const originSummary: FingerprintSummaryStored = { + root: "0xorigin", + groupHash: "0xgroup", + bucketId: "a".repeat(32), + }; + const escalationSummary: FingerprintSummaryStored = { + root: "0xescalation", + groupHash: "0xgroup", + bucketId: "b".repeat(32), + }; + const dbGet = () => db.getSessionRecordBySessionId as unknown as ReturnType; @@ -166,6 +178,7 @@ describe("CaptchaManager", () => { simdReadings: { supported: true, results: [] }, dnsEvent: { receivedAt: new Date() }, d: { k1: "v1", k2: false }, + fingerprintSummary: escalationSummary, } as unknown as Session; dbGet().mockResolvedValue(session); @@ -258,6 +271,49 @@ describe("CaptchaManager", () => { }); }); + it("fills the fingerprint summary from origin when the escalation has none", async () => { + const origin = { + sessionId: "origin", + captchaType: CaptchaType.pow, + fingerprintSummary: originSummary, + } as unknown as Session; + const escalation = { + sessionId: "esc", + originSessionId: "origin", + captchaType: CaptchaType.puzzle, + d: { k1: "escalation-value" }, + simdReadings: { supported: false, reason: "n/a" }, + dnsEvent: { receivedAt: new Date() }, + } as unknown as Session; + dbGet().mockResolvedValueOnce(escalation).mockResolvedValueOnce(origin); + + const got = + await captchaManager.getSessionRecordWithOriginFallback("esc"); + + expect(got?.fingerprintSummary).toEqual(originSummary); + expect(got?.sessionId).toBe("esc"); + }); + + it("keeps the escalation's own fingerprint summary over the origin's", async () => { + const origin = { + sessionId: "origin", + captchaType: CaptchaType.pow, + fingerprintSummary: originSummary, + } as unknown as Session; + const escalation = { + sessionId: "esc", + originSessionId: "origin", + captchaType: CaptchaType.puzzle, + fingerprintSummary: escalationSummary, + } as unknown as Session; + dbGet().mockResolvedValueOnce(escalation).mockResolvedValueOnce(origin); + + const got = + await captchaManager.getSessionRecordWithOriginFallback("esc"); + + expect(got?.fingerprintSummary).toEqual(escalationSummary); + }); + it("does not walk to the origin when the escalation has everything", async () => { const escalation = { sessionId: "esc", @@ -266,6 +322,7 @@ describe("CaptchaManager", () => { d: { k1: "escalation-value" }, simdReadings: { supported: false, reason: "n/a" }, dnsEvent: { receivedAt: new Date() }, + fingerprintSummary: escalationSummary, } as unknown as Session; dbGet().mockResolvedValueOnce(escalation); diff --git a/packages/provider/src/tests/unit/tasks/frictionless/frictionlessTasks.unit.test.ts b/packages/provider/src/tests/unit/tasks/frictionless/frictionlessTasks.unit.test.ts index d72c1acd1b..593d1580c5 100644 --- a/packages/provider/src/tests/unit/tasks/frictionless/frictionlessTasks.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/frictionless/frictionlessTasks.unit.test.ts @@ -275,6 +275,54 @@ describe("Frictionless Task Manager", () => { ); }); + it("records the fingerprint summary on the session at issuance", async () => { + // The proof now arrives with the frictionless request, so the bucket + // exists for every session, not only those that went on to a PoW. + vi.mocked(db.storeSessionRecord).mockResolvedValue(undefined); + const fingerprintSummary = { + root: "0xroot", + groupHash: "0xgroup", + bucketId: "9f1c3a0e7b2d4c6a8e0f1a2b3c4d5e6f", + }; + + frictionlessTaskManager.setSessionParams({ + token: "tok-fp", + score: 0.5, + threshold: 0.7, + scoreComponents: { baseScore: 0.5 }, + ipAddress: getCompositeIpAddress("1.2.3.4"), + webView: false, + iFrame: false, + decryptedHeadHash: "", + siteKey: "siteKey-fp", + fingerprintSummary, + }); + + await frictionlessTaskManager.sendImageCaptcha({ + solvedImagesCount: 0, + }); + + expect(db.storeSessionRecord).toHaveBeenCalledWith( + expect.objectContaining({ fingerprintSummary }), + ); + }); + + it("leaves the fingerprint summary off a session that sent no proof", async () => { + vi.mocked(db.storeSessionRecord).mockResolvedValue(undefined); + + const session = await frictionlessTaskManager.createSession({ + token: "tok-no-fp", + score: 0.5, + threshold: 0.7, + scoreComponents: { baseScore: 0.5 }, + ipAddress: getCompositeIpAddress("1.2.3.4"), + siteKey: "siteKey-no-fp", + captchaType: CaptchaType.image, + }); + + expect(session).not.toHaveProperty("fingerprintSummary"); + }); + it("leaves clientMetaData off a session the widget reported none for", async () => { // Absent rather than an empty subdocument, so a reader can tell // "no session id" from "session id we failed to record". diff --git a/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts b/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts index e45c8a6a0f..c2d45374e3 100644 --- a/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/imgCaptcha/imgCaptchaTasks.unit.test.ts @@ -660,6 +660,7 @@ describe("ImgCaptchaManager", () => { salt: "0x00", threshold: 0.8, deadlineTimestamp: new Date(Date.now() + 10_000), + sessionId: "session-1", } as unknown as PendingImageCaptchaRequest); vi.spyOn( imgCaptchaManager, @@ -757,6 +758,34 @@ describe("ImgCaptchaManager", () => { expect(storedCommitment()?.inputMethods).toBeUndefined(); }); + it("copies the session's fingerprint summary onto the commitment", async () => { + const fingerprintSummary = { + root: "0xroot", + groupHash: "0xgroup", + bucketId: "9f1c3a0e7b2d4c6a8e0f1a2b3c4d5e6f", + }; + vi.mocked(db.getSessionRecordBySessionId).mockResolvedValue({ + sessionId: "session-1", + fingerprintSummary, + } as unknown as Awaited< + ReturnType + >); + + await submit({ coords: [3, 4, 15, 25] }, { coords: [16, 26] }); + + expect(db.getSessionRecordBySessionId).toHaveBeenCalledWith("session-1"); + expect(storedCommitment()?.fingerprintSummary).toStrictEqual( + fingerprintSummary, + ); + }); + + it("stores no fingerprint summary when the session has none", async () => { + await submit({ coords: [3, 4, 15, 25] }, { coords: [16, 26] }); + + expect(storedCommitment()).toBeDefined(); + expect(storedCommitment()).not.toHaveProperty("fingerprintSummary"); + }); + it("still rejects repeated (0, 0) from a widget that does not declare input methods", async () => { const result = await submit({ coords: [0, 0, 0, 0] }, { coords: [0, 0] }); diff --git a/packages/types-database/src/types/provider.ts b/packages/types-database/src/types/provider.ts index 168baa7bda..d6ced12ea7 100644 --- a/packages/types-database/src/types/provider.ts +++ b/packages/types-database/src/types/provider.ts @@ -27,6 +27,7 @@ import { DecisionMachineLanguage, DecisionMachineRuntime, DecisionMachineScope, + type FingerprintSummaryStored, type IconOrderCaptchaStored, IpAddressType, ModeEnum, @@ -223,6 +224,38 @@ CaptchaRecordSchema.index({ datasetId: 1, solved: 1 }); // of materialising the full matched set. CaptchaRecordSchema.index({ datasetId: 1, solved: 1, randomKey: 1 }); +// Mongoose runs `strict: true` and silently drops undeclared paths, so every +// record that carries a fingerprint summary has to declare this field. +const FingerprintSummaryRecordSchema = new Schema( + { + root: { type: String, required: true }, + groupHash: { type: String, required: true }, + bucketId: { type: String, required: false }, + colorDepth: { type: Number, required: false }, + hardwareConcurrency: { type: Number, required: false }, + disclosed: { + type: new Schema( + { + timezone: { type: String, required: false }, + languages: { type: String, required: false }, + architecture: { type: Number, required: false }, + cookiesEnabled: { type: Boolean, required: false }, + sessionStorage: { type: Boolean, required: false }, + localStorage: { type: Boolean, required: false }, + }, + { _id: false }, + ), + required: false, + }, + }, + { _id: false }, +); + +const fingerprintSummaryField = { + type: FingerprintSummaryRecordSchema, + required: false, +}; + export const PoWCaptchaRecordSchema = new Schema({ challenge: { type: String, required: true }, dappAccount: { type: String, required: true }, @@ -297,35 +330,7 @@ export const PoWCaptchaRecordSchema = new Schema({ required: false, }, providerSignature: { type: String, required: true }, - // Mongoose runs `strict: true` and silently drops undeclared paths, so an - // omission here means the field is written and never stored. - fingerprintSummary: { - type: new Schema( - { - root: { type: String, required: true }, - groupHash: { type: String, required: true }, - bucketId: { type: String, required: false }, - colorDepth: { type: Number, required: false }, - hardwareConcurrency: { type: Number, required: false }, - disclosed: { - type: new Schema( - { - timezone: { type: String, required: false }, - languages: { type: String, required: false }, - architecture: { type: Number, required: false }, - cookiesEnabled: { type: Boolean, required: false }, - sessionStorage: { type: Boolean, required: false }, - localStorage: { type: Boolean, required: false }, - }, - { _id: false }, - ), - required: false, - }, - }, - { _id: false }, - ), - required: false, - }, + fingerprintSummary: fingerprintSummaryField, // Internal ML labelling applied by superadmins via the audit page. label: { type: String, enum: Object.values(CaptchaLabel), required: false }, labelReason: { type: String, required: false }, @@ -401,6 +406,7 @@ PoWCaptchaRecordSchema.index( * type shows up as an empty column rather than a compile error. */ const interactiveCaptchaRecordFields = { + fingerprintSummary: fingerprintSummaryField, challenge: { type: String, required: true }, dappAccount: { type: String, required: true }, userAccount: { type: String, required: true }, @@ -624,6 +630,7 @@ indexInteractiveCaptchaRecord(IconOrderCaptchaRecordSchema); indexInteractiveCaptchaRecord(AudioCaptchaRecordSchema); export const UserCommitmentRecordSchema = new Schema({ + fingerprintSummary: fingerprintSummaryField, userAccount: { type: String, required: true }, dappAccount: { type: String, required: true }, providerAccount: { type: String, required: true }, @@ -850,6 +857,7 @@ ScheduledTaskRecordSchema.index({ _id: 1, status: 1 }); export type SessionRecord = mongoose.Document & Session; export const SessionRecordSchema = new Schema({ + fingerprintSummary: fingerprintSummaryField, sessionId: { type: String, required: true }, createdAt: { type: Date, required: true }, token: { type: String, required: true }, @@ -1269,6 +1277,9 @@ export const SESSION_PROJECTION = { // so session-level readers see the same clientSessionId the // captcha record carries. clientMetaData: 1, + // Copied onto the captcha record a session leads to, and walked to + // the origin for escalations; see getSessionRecordWithOriginFallback. + fingerprintSummary: 1, // captchaType is required by the peek-before-consume path // in `CaptchaManager.isValidRequest` — without it, every // escalation peek would compare `undefined !== ` @@ -1554,6 +1565,7 @@ export interface IProviderDatabase extends IDatabase { userSubmitted?: boolean, userSignature?: string, ipInfo?: IPInfoResponse, + fingerprintSummary?: FingerprintSummaryStored, ): Promise; getPowCaptchaRecordByChallenge( @@ -1588,6 +1600,7 @@ export interface IProviderDatabase extends IDatabase { ja4: string, sessionId?: string, ipInfo?: IPInfoResponse, + fingerprintSummary?: FingerprintSummaryStored, ): Promise; getPuzzleCaptchaRecordByChallenge( diff --git a/packages/types-database/src/types/schemas.test.ts b/packages/types-database/src/types/schemas.test.ts index 6848b10751..3222331560 100644 --- a/packages/types-database/src/types/schemas.test.ts +++ b/packages/types-database/src/types/schemas.test.ts @@ -43,6 +43,7 @@ import { ScheduledTaskRecordSchema, ScheduledTaskSchema, SessionRecordSchema, + UserCommitmentRecordSchema, UserSolutionRecordSchema, } from "./provider.js"; import { SpamEmailDomainRecordSchema } from "./spamEmailDomain.js"; @@ -872,3 +873,33 @@ describe("the puzzle render record", () => { expect(session.puzzleLevel).toBe(3); }); }); + +// The summary used to be declared on the PoW schema alone, so a session, an +// image commitment or a puzzle that carried one had it dropped on write. Every +// schema a summary is copied onto, and its central-store twin, has to keep it. +describe("fingerprint summary on every record that carries one", () => { + const summary = { + root: "0xroot", + groupHash: "0xgroup", + bucketId: "9f1c3a0e7b2d4c6a8e0f1a2b3c4d5e6f", + colorDepth: 24, + hardwareConcurrency: 8, + disclosed: { timezone: "Europe/London", cookiesEnabled: true }, + }; + const schemas: [string, Schema][] = [ + ["SessionRecordSchema", SessionRecordSchema], + ["StoredSessionRecordSchema", StoredSessionRecordSchema], + ["UserCommitmentRecordSchema", UserCommitmentRecordSchema], + ["StoredUserCommitmentRecordSchema", StoredUserCommitmentRecordSchema], + ["PuzzleCaptchaRecordSchema", PuzzleCaptchaRecordSchema], + ["StoredPuzzleCaptchaRecordSchema", StoredPuzzleCaptchaRecordSchema], + ["PoWCaptchaRecordSchema", PoWCaptchaRecordSchema], + ["StoredPoWCaptchaRecordSchema", StoredPoWCaptchaRecordSchema], + ]; + + it.each(schemas)("%s keeps it", (_name, schema) => { + const doc = validate(schema, { fingerprintSummary: summary }); + + expect(doc.toObject().fingerprintSummary).toStrictEqual(summary); + }); +}); diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 70c48cea6f..d8d99bf2d0 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -783,6 +783,9 @@ export const GetFrictionlessCaptchaChallengeRequestBody = object({ // refresh. Only honoured alongside a `refreshOf` naming a puzzle session // on a site with the switch enabled, and only ever moves puzzle to image. [ApiParams.switchToImage]: boolean().optional(), + // Encoded proof of fingerprint. Sent here so every session carries the + // device bucket whichever captcha type it is served, not only PoW. + [ApiParams.fingerprintProof]: boundedString(INPUT_LIMITS.TOKEN).optional(), }); export type GetFrictionlessCaptchaChallengeRequestBodyOutput = output< diff --git a/packages/types/src/provider/database.ts b/packages/types/src/provider/database.ts index 88b9d6ef4a..edb15d8576 100644 --- a/packages/types/src/provider/database.ts +++ b/packages/types/src/provider/database.ts @@ -182,6 +182,74 @@ export interface ClientMetaData { clientSessionId?: string; } +/** + * The disclosed values that are fixed by the installation and the host, kept + * individually alongside `groupHash`, which is a hash of exactly these. + * + * `groupHash` alone is enough to group by, but it is one-way: a cohort could be + * counted and never described, so "which timezone is this concentrated in?" + * would be unanswerable after the fact. Keeping the values costs about 170 + * bytes a record against a whole proof's ~7.8KB, and the hash stays + * precomputed so it can carry an index. + * + * `languages` is a nested array in the component set and is stored as canonical + * JSON, so it is one indexable scalar rather than an array path. + */ +export interface FingerprintDisclosedValuesStored { + timezone?: string; + languages?: string; + architecture?: number; + cookiesEnabled?: boolean; + sessionStorage?: boolean; + localStorage?: boolean; +} + +/** + * What is worth keeping from a verified fingerprint proof. + * + * The proof itself is not persisted: each disclosure carries a full Merkle + * path, which is large and of no use once the proof has been checked. + * + * root the commitment to the whole component set. Both this and the + * account address are functions of that one set, so one root + * belongs to one address and vice versa. + * groupHash a hash of the disclosed values in `disclosed`. + * bucketId the disclosed `deviceBucket` digest, an opaque hash of the device + * traits that do not change between sessions. Carried through only + * because its disclosure hashed to `root`, so it cannot be + * presented independently of the identity the same commitment + * produced. Absent for a client that did not disclose it. + * colorDepth / hardwareConcurrency + * disclosed values kept per record, so a cohort can be described + * rather than only counted. + */ +export interface FingerprintSummaryStored { + root: string; + groupHash: string; + bucketId?: string; + colorDepth?: number; + hardwareConcurrency?: number; + disclosed?: FingerprintDisclosedValuesStored; +} + +export const FingerprintDisclosedValuesSchema = object({ + timezone: string().optional(), + languages: string().optional(), + architecture: number().optional(), + cookiesEnabled: boolean().optional(), + sessionStorage: boolean().optional(), + localStorage: boolean().optional(), +}); + +export const FingerprintSummarySchema = object({ + root: string(), + groupHash: string(), + bucketId: string().optional(), + colorDepth: number().optional(), + hardwareConcurrency: number().optional(), + disclosed: FingerprintDisclosedValuesSchema.optional(), +}); + /** * Internal classification labels applied by superadmins from the audit page to * build supervised ML training sets. Stored directly on the captcha record @@ -257,6 +325,9 @@ export interface StoredCaptcha { labelReason?: string; labelledBy?: string; labelledAt?: Date; + // From the fingerprint proof sent with the frictionless request, copied + // from the session, or from a proof submitted with a PoW solution. + fingerprintSummary?: FingerprintSummaryStored; } export interface UserCommitment extends StoredCaptcha { @@ -356,6 +427,7 @@ export const UserCommitmentSchema = object({ labelReason: string().optional(), labelledBy: string().optional(), labelledAt: date().optional(), + fingerprintSummary: FingerprintSummarySchema.optional(), }) satisfies ZodType; // Zod schema for ScoreComponents @@ -591,6 +663,7 @@ export const SessionSchema = object({ }).optional(), // See Session.clientMetaData. clientMetaData: ClientMetaDataDbSchema.optional(), + fingerprintSummary: FingerprintSummarySchema.optional(), }) satisfies ZodType; // Session now includes all frictionless token fields @@ -783,80 +856,13 @@ export type Session = { // `clientSessionId` the dapp server sends against the one recorded here / // on the captcha record, and reject the token when they disagree. clientMetaData?: ClientMetaData; + fingerprintSummary?: FingerprintSummaryStored; }; // Zod schema for PoWCaptchaStored // PoWCaptchaStored = PoWCaptchaUser (minus requestedAtTimestamp) + StoredCaptcha // Note: challenge uses PowChallengeIdSchema for runtime validation // The PoWCaptchaStored interface enforces the PoWChallengeId template literal type at compile time -/** - * The disclosed values that are fixed by the installation and the host, kept - * individually alongside `groupHash`, which is a hash of exactly these. - * - * `groupHash` alone is enough to group by, but it is one-way: a cohort could be - * counted and never described, so "which timezone is this concentrated in?" - * would be unanswerable after the fact. Keeping the values costs about 170 - * bytes a record against a whole proof's ~7.8KB, and the hash stays - * precomputed so it can carry an index. - * - * `languages` is a nested array in the component set and is stored as canonical - * JSON, so it is one indexable scalar rather than an array path. - */ -export interface FingerprintDisclosedValuesStored { - timezone?: string; - languages?: string; - architecture?: number; - cookiesEnabled?: boolean; - sessionStorage?: boolean; - localStorage?: boolean; -} - -/** - * What is worth keeping from a verified fingerprint proof. - * - * The proof itself is not persisted: each disclosure carries a full Merkle - * path, which is large and of no use once the proof has been checked. - * - * root the commitment to the whole component set. Both this and the - * account address are functions of that one set, so one root - * belongs to one address and vice versa. - * groupHash a hash of the disclosed values in `disclosed`. - * bucketId the disclosed `deviceBucket` digest, an opaque hash of the device - * traits that do not change between sessions. Carried through only - * because its disclosure hashed to `root`, so it cannot be - * presented independently of the identity the same commitment - * produced. Absent for a client that did not disclose it. - * colorDepth / hardwareConcurrency - * disclosed values kept per record, so a cohort can be described - * rather than only counted. - */ -export interface FingerprintSummaryStored { - root: string; - groupHash: string; - bucketId?: string; - colorDepth?: number; - hardwareConcurrency?: number; - disclosed?: FingerprintDisclosedValuesStored; -} - -export const FingerprintDisclosedValuesSchema = object({ - timezone: string().optional(), - languages: string().optional(), - architecture: number().optional(), - cookiesEnabled: boolean().optional(), - sessionStorage: boolean().optional(), - localStorage: boolean().optional(), -}); - -export const FingerprintSummarySchema = object({ - root: string(), - groupHash: string(), - bucketId: string().optional(), - colorDepth: number().optional(), - hardwareConcurrency: number().optional(), - disclosed: FingerprintDisclosedValuesSchema.optional(), -}); - export const PoWCaptchaStoredSchema = object({ // From PoWCaptchaUser (extends PoWCaptcha) challenge: PowChallengeIdSchema, @@ -894,7 +900,6 @@ export const PoWCaptchaStoredSchema = object({ clickEvents: array(object({}).catchall(any())).optional(), deviceCapability: string().optional(), behavioralDataPacked: BehavioralDataPackedSchema.optional(), - fingerprintSummary: FingerprintSummarySchema.optional(), // Internal ML labelling (see StoredCaptcha.label) label: CaptchaLabelSchema.optional(), labelReason: string().optional(), @@ -916,9 +921,7 @@ export type PendingImageCaptchaRequest = { export interface PoWCaptchaStored extends Omit, - StoredCaptcha { - fingerprintSummary?: FingerprintSummaryStored; -} + StoredCaptcha {} /** * Everything needed to reproduce a served puzzle exactly, for the portal's @@ -1017,6 +1020,7 @@ export const UserSolutionSchema = CaptchaSolutionSchema.extend({ checked: boolean(), commitmentId: string(), createdAt: date(), + fingerprintSummary: FingerprintSummarySchema.optional(), }); export type UserSolution = zInfer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ae8a07cfff..25284cd57e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2306,6 +2306,9 @@ importers: '@prosopo/common': specifier: workspace:* version: link:../common + '@prosopo/fingerprint': + specifier: workspace:* + version: link:../fingerprint '@prosopo/locale': specifier: workspace:* version: link:../locale