From 1ba21e83bc1b0d4d2039958fca9d90206a5402ac Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 25 Aug 2026 16:38:44 +0100 Subject: [PATCH 1/4] feat(web-bot-auth): RFC 9421 verifier + authenticated frictionless flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New package @prosopo/web-bot-auth: RFC 9421 HTTP Message Signatures verifier using @noble/curves/ed25519 (no Cloudflare dep). Handles both bare-string (OpenAI) and dictionary (Google) Signature-Agent header forms. 17 unit tests including full Ed25519 round-trip against golden vectors and replay defence. Frictionless flow gains an authenticated fast-path — any non-deferToVerify Allow rule matching the userScope (webBotAuthAgent, ip_cidr, ja4, ua, asn, country) mints an `authenticated` session, skipping decrypt/detect/DM. Web Bot Auth is one of many qualifiers, not the only one. New endpoint POST /v1/prosopo/provider/client/authenticated/verify enforces IP binding: the operator must forward the client IP and it must equal the session's captured IP. serverChecked flow gives single-use. ipMatchesSession extracted to a pure function with 13 unit tests. Schema: - CaptchaType.authenticated added - AccessPolicyType.Allow added - Session record gains `agent: boolean` and `webBotAuthAgent: string` - user-access-policy schema gains webBotAuthAgent scope field (rule.ts, ruleRecord.ts, ruleInput/userScopeInput.ts with URL normalisation, Mongoose + Redis TAG index) - ClientSettings gains `allowAgents?: boolean` (opt-in gate) - GetFrictionlessCaptchaResponse carries `agent` on authenticated responses Widget: - AuthenticatedBadge component: on mount encodes ProcaptchaToken with sessionId as commitmentId, fires events.onHuman. Renders compact "Verified agent: chatgpt.com" or "Trusted request" fallback. - ProcaptchaFrictionless dispatch branch for CaptchaType.authenticated Fixes: - dev/config vite.esm.config.ts: @ts-expect-error on Rolldown polyfillRequire - dev/config configs.test.ts: narrow cast for Vitest custom-provider coverage - provider/util.ts: Mongoose 8 Document._id cast to Types.ObjectId --- dev/config/src/vite/configs.test.ts | 18 +- dev/config/src/vite/vite.esm.config.ts | 5 +- packages/cli/src/RateLimiter.ts | 4 + .../src/AuthenticatedBadge.tsx | 140 ++++++++++ .../src/ProcaptchaFrictionless.tsx | 28 +- .../src/customDetectBot.ts | 2 + packages/provider/package.json | 1 + .../src/api/blacklistRequestInspector.ts | 10 + .../handler.ts | 85 ++++++ packages/provider/src/api/verify.ts | 109 ++++++++ .../tasks/frictionless/frictionlessTasks.ts | 115 +++++++++ .../src/tasks/frictionless/ipMatch.ts | 40 +++ .../src/tasks/spam/checkTrafficFilter.ts | 3 + .../tasks/frictionless/ipMatch.unit.test.ts | 122 +++++++++ packages/provider/src/util.ts | 5 +- packages/provider/tsconfig.cjs.json | 3 + packages/provider/tsconfig.json | 3 + packages/types-database/src/types/provider.ts | 7 + .../src/client/captchaType/captchaType.ts | 12 +- packages/types/src/client/settings.ts | 11 + packages/types/src/procaptcha/props.ts | 5 + packages/types/src/provider/api.ts | 16 +- packages/types/src/provider/database.ts | 11 + .../types/src/provider/matchedAccessRule.ts | 6 +- .../src/mongoose/mongooseRuleSchema.ts | 1 + .../src/redis/redisRuleIndex.ts | 1 + packages/user-access-policy/src/rule.ts | 16 ++ .../src/ruleInput/userScopeInput.ts | 21 ++ packages/user-access-policy/src/ruleRecord.ts | 1 + .../src/tests/transformRule.unit.test.ts | 1 + packages/web-bot-auth/package.json | 48 ++++ packages/web-bot-auth/src/base64.ts | 39 +++ packages/web-bot-auth/src/index.ts | 47 ++++ packages/web-bot-auth/src/jwksResolver.ts | 89 +++++++ .../web-bot-auth/src/parseSignatureAgent.ts | 50 ++++ packages/web-bot-auth/src/signatureBase.ts | 54 ++++ packages/web-bot-auth/src/structuredFields.ts | 242 ++++++++++++++++++ .../src/tests/signatureBase.test.ts | 64 +++++ .../src/tests/structuredFields.test.ts | 79 ++++++ .../web-bot-auth/src/tests/verify.test.ts | 191 ++++++++++++++ packages/web-bot-auth/src/verify.ts | 180 +++++++++++++ packages/web-bot-auth/tsconfig.cjs.json | 18 ++ packages/web-bot-auth/tsconfig.json | 19 ++ packages/web-bot-auth/tsconfig.types.json | 9 + packages/web-bot-auth/vite.cjs.config.ts | 23 ++ packages/web-bot-auth/vite.esm.config.ts | 20 ++ packages/web-bot-auth/vite.test.config.ts | 19 ++ 47 files changed, 1982 insertions(+), 11 deletions(-) create mode 100644 packages/procaptcha-frictionless/src/AuthenticatedBadge.tsx create mode 100644 packages/provider/src/tasks/frictionless/ipMatch.ts create mode 100644 packages/provider/src/tests/unit/tasks/frictionless/ipMatch.unit.test.ts create mode 100644 packages/web-bot-auth/package.json create mode 100644 packages/web-bot-auth/src/base64.ts create mode 100644 packages/web-bot-auth/src/index.ts create mode 100644 packages/web-bot-auth/src/jwksResolver.ts create mode 100644 packages/web-bot-auth/src/parseSignatureAgent.ts create mode 100644 packages/web-bot-auth/src/signatureBase.ts create mode 100644 packages/web-bot-auth/src/structuredFields.ts create mode 100644 packages/web-bot-auth/src/tests/signatureBase.test.ts create mode 100644 packages/web-bot-auth/src/tests/structuredFields.test.ts create mode 100644 packages/web-bot-auth/src/tests/verify.test.ts create mode 100644 packages/web-bot-auth/src/verify.ts create mode 100644 packages/web-bot-auth/tsconfig.cjs.json create mode 100644 packages/web-bot-auth/tsconfig.json create mode 100644 packages/web-bot-auth/tsconfig.types.json create mode 100644 packages/web-bot-auth/vite.cjs.config.ts create mode 100644 packages/web-bot-auth/vite.esm.config.ts create mode 100644 packages/web-bot-auth/vite.test.config.ts diff --git a/dev/config/src/vite/configs.test.ts b/dev/config/src/vite/configs.test.ts index 28d73a2a07..f7a3daae00 100644 --- a/dev/config/src/vite/configs.test.ts +++ b/dev/config/src/vite/configs.test.ts @@ -111,18 +111,28 @@ describe("ViteTestConfig", () => { // the repo-root globs only match `packages/*/src/**`. asPackage(); const config = ViteTestConfig(); - expect(config.test?.coverage?.include).toContain("src/**/*.ts"); - expect(config.test?.coverage?.exclude).toContain("src/**/*.test.ts"); + // Vitest's coverage type is a discriminated union on `provider`; the + // custom-provider variant doesn't declare `include`/`exclude` even + // though they're valid at runtime for every provider. Cast to the v8 + // variant (which does declare them) for assertion purposes only. + const coverage = config.test?.coverage as + | { include?: unknown; exclude?: unknown } + | undefined; + expect(coverage?.include).toContain("src/**/*.ts"); + expect(coverage?.exclude).toContain("src/**/*.test.ts"); }); it("falls back to repo-wide globs when there is no src directory", () => { asRepoRoot(); const config = ViteTestConfig(); - expect(config.test?.coverage?.include).toEqual([ + const coverage = config.test?.coverage as + | { include?: unknown; exclude?: unknown } + | undefined; + expect(coverage?.include).toEqual([ "packages/*/src/**", "captcha/packages/*/src/**", ]); - expect(config.test?.coverage?.exclude).toContain("**/node_modules/**"); + expect(coverage?.exclude).toContain("**/node_modules/**"); }); it("adds the tsconfig-paths plugin only when given a tsconfig", () => { diff --git a/dev/config/src/vite/vite.esm.config.ts b/dev/config/src/vite/vite.esm.config.ts index 85a67006c5..e69223e420 100644 --- a/dev/config/src/vite/vite.esm.config.ts +++ b/dev/config/src/vite/vite.esm.config.ts @@ -84,7 +84,10 @@ export default async function ( // package (e.g. catcher-demo importing @prosopo/util) dies with // 'Module "node:module" has been externalized for browser // compatibility' before React can mount. Rollup emitted no such - // runtime, so this only bites under Vite 8. + // runtime, so this only bites under Vite 8. The option is a + // Rolldown extension; Vite 8's re-exported Rollup types don't + // carry it yet. + // @ts-expect-error — Rolldown option, not in Rollup's OutputOptions polyfillRequire: false, }, }, diff --git a/packages/cli/src/RateLimiter.ts b/packages/cli/src/RateLimiter.ts index 073c12f8a4..f9dc3aadea 100644 --- a/packages/cli/src/RateLimiter.ts +++ b/packages/cli/src/RateLimiter.ts @@ -119,6 +119,10 @@ export const getRateLimitConfig = () => { windowMs: process.env.PROSOPO_VERIFY_PUZZLE_CAPTCHA_SOLUTION_WINDOW, limit: process.env.PROSOPO_VERIFY_PUZZLE_CAPTCHA_SOLUTION_LIMIT, }, + [ClientApiPaths.VerifyAuthenticatedSession]: { + windowMs: process.env.PROSOPO_VERIFY_AUTHENTICATED_SESSION_WINDOW, + limit: process.env.PROSOPO_VERIFY_AUTHENTICATED_SESSION_LIMIT, + }, [AdminApiPaths.DnsEvent]: { windowMs: process.env.PROSOPO_DNS_EVENT_WINDOW, limit: process.env.PROSOPO_DNS_EVENT_LIMIT, diff --git a/packages/procaptcha-frictionless/src/AuthenticatedBadge.tsx b/packages/procaptcha-frictionless/src/AuthenticatedBadge.tsx new file mode 100644 index 0000000000..93dcfe1a44 --- /dev/null +++ b/packages/procaptcha-frictionless/src/AuthenticatedBadge.tsx @@ -0,0 +1,140 @@ +// 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. + +// Widget for the `authenticated` captcha outcome — no user-facing challenge, +// just a visible acknowledgement that Web Bot Auth verification succeeded. +// Mounts once, immediately encodes a ProcaptchaToken with the sessionId as +// its commitmentId (matching what /client/authenticated/verify decodes) and +// fires `onHuman`. The badge itself is purely presentational; the token +// submission happens in the mount effect. +// +// The token carries empty signature bags (`{provider: {}, user: {}}`) — the +// authenticated verify endpoint does not check the token's user or provider +// signatures because there was no captcha challenge to sign. Only the dApp +// server's signature on the timestamp is checked at verify time, and that +// is applied by the dApp on the way to /verify (not by the widget here). + +import { getDefaultEvents } from "@prosopo/procaptcha-common"; +import { + type Account, + ApiParams, + CaptchaType, + type ProcaptchaCallbacks, + type RandomProvider, + encodeProcaptchaOutput, +} from "@prosopo/types"; +import { type FC, useEffect, useRef } from "react"; + +export type AuthenticatedBadgeProps = { + sessionId: string; + agent?: string; + dapp: string; + userAccount: Account; + provider: RandomProvider; + callbacks: ProcaptchaCallbacks; +}; + +const displayHost = (agent?: string): string => { + if (!agent) return "unknown"; + try { + return new URL(agent).hostname; + } catch { + return agent; + } +}; + +export const AuthenticatedBadge: FC = ({ + sessionId, + agent, + dapp, + userAccount, + provider, + callbacks, +}) => { + // One-shot: React 18 StrictMode double-invokes effects in development; + // this guard makes sure the token is emitted exactly once even under + // double-mount. Production doesn't double-invoke, so this is a + // belt-and-braces against local-dev confusion. + const emittedRef = useRef(false); + + useEffect(() => { + if (emittedRef.current) return; + emittedRef.current = true; + const events = getDefaultEvents(callbacks); + const token = encodeProcaptchaOutput({ + [ApiParams.providerUrl]: provider.provider.url, + [ApiParams.user]: userAccount.account.address, + [ApiParams.dapp]: dapp, + // Verify endpoint reads commitmentId as the sessionId — reusing + // the existing slot avoids a token codec bump for this one flow. + [ApiParams.commitmentId]: sessionId, + [ApiParams.timestamp]: Date.now().toString(), + [ApiParams.signature]: { + [ApiParams.provider]: {}, + [ApiParams.user]: {}, + }, + [ApiParams.captchaType]: CaptchaType.authenticated, + }); + events.onHuman(token); + }, [sessionId, dapp, userAccount, provider, callbacks]); + + return ( +
+ + + {agent ? ( + <> + Verified agent: {displayHost(agent)} + + ) : ( + // No Signature-Agent URL on the response — the operator's Allow + // rule matched on a non-Web-Bot-Auth condition (IP CIDR, JA4, + // UA substring, ASN, country). Fall back to generic copy so + // the operator isn't misled about which qualifier fired. + Trusted request + )} + +
+ ); +}; diff --git a/packages/procaptcha-frictionless/src/ProcaptchaFrictionless.tsx b/packages/procaptcha-frictionless/src/ProcaptchaFrictionless.tsx index 75e5a3500e..99bb0e3e42 100644 --- a/packages/procaptcha-frictionless/src/ProcaptchaFrictionless.tsx +++ b/packages/procaptcha-frictionless/src/ProcaptchaFrictionless.tsx @@ -29,6 +29,7 @@ import { } from "@prosopo/types"; import { darkTheme, lightTheme } from "@prosopo/widget-skeleton"; import { useEffect, useRef, useState } from "react"; +import { AuthenticatedBadge } from "./AuthenticatedBadge.js"; import customDetectBot from "./customDetectBot.js"; import { evaluateFrictionlessResult } from "./frictionlessResultGuard.js"; import { @@ -257,7 +258,31 @@ export const ProcaptchaFrictionless = ({ mountCountRef.current += 1; const mountKey = mountCountRef.current; - if (captchaType === CaptchaType.image) { + if (captchaType === CaptchaType.authenticated) { + // Web Bot Auth pre-verified pass-through. No challenge, no + // interaction — the badge component encodes and fires the token + // on mount. Skip the loader chain the other branches use because + // there is no captcha module to lazy-import here. + if (!frictionlessState.sessionId) { + events.onError( + new Error( + "authenticated captcha response missing sessionId — provider is misbehaving", + ), + ); + fallOverWithStyle(); + return; + } + setComponentToRender( + , + ); + } else if (captchaType === CaptchaType.image) { const Procaptcha = await ProcaptchaLoader(); setComponentToRender( => { const userAgent = requestHeaders["user-agent"] ? requestHeaders["user-agent"].toString() @@ -79,6 +84,7 @@ export const getRequestUserScope = ( ...(coords && { coords }), ...(countryCode && { countryCode }), ...(typeof asn === "number" && { asn }), + ...(webBotAuthAgent && { webBotAuthAgent }), // Always populated (even "unknown") — derived from the request UA, not // trusted from a client hint. Present unconditionally so an OS // allow-list (block everything not on the list) still matches requests @@ -100,6 +106,7 @@ const SCALAR_USER_SCOPE_FIELDS = [ "countryCode", "asn", "os", + "webBotAuthAgent", ] as const satisfies ReadonlyArray; // Derive the populated-scope field list for a matched rule (the same shape @@ -223,6 +230,9 @@ const CAPTCHA_TYPE_HARSHNESS: Record = { // if the enum grows. Restrict-with-frictionless wouldn't make operational // sense and ranks at the bottom of the captcha tiers if it ever appears. [CaptchaType.frictionless]: 0, + // authenticated is a pre-verified pass-through — never a Restrict target. + // Rank at 0 alongside frictionless for the same "should never rank" reason. + [CaptchaType.authenticated]: 0, }; // Harshness within an equal-specificity tier (issue #3713). On equal diff --git a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts index 302da3f10f..4e9be87946 100644 --- a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts +++ b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts @@ -25,6 +25,7 @@ import { AccessPolicyType, type AccessRulesStorage, } from "@prosopo/user-access-policy"; +import { verifyWebBotAuth } from "@prosopo/web-bot-auth"; import { flatten, isProtectDeployment, sanitisePageUrl } from "@prosopo/util"; import type { NextFunction, Request, Response } from "express"; import { v4 as uuidv4 } from "uuid"; @@ -541,6 +542,22 @@ export default ( req.headers["accept-language"] || "", ); + // Web Bot Auth (RFC 9421): if the request carries a valid Ed25519 + // signature and the signer's JWKS at /.well-known/http-message- + // signatures-directory verifies it, promote the canonical signer + // URL onto the userScope so `webBotAuthAgent` access rules can + // match on the verified identity. Unsigned traffic falls through + // with webBotAuthAgent=undefined and hits the normal detector + // stack. + const verified = await verifyWebBotAuth({ + method: req.method, + url: `https://${req.headers.host ?? ""}${req.originalUrl ?? req.url}`, + headers: flatten(req.headers), + }); + const verifiedSignerUrl = verified.verified + ? verified.signerUrl + : undefined; + const userScope = getRequestUserScope( flatten(req.headers), req.ja4, @@ -550,6 +567,7 @@ export default ( undefined, countryCode, asn, + verifiedSignerUrl, ); // Fan out the three independent post-shortcircuit awaits: @@ -575,6 +593,73 @@ export default ( ), ]); + // Authenticated fast-path. Fires when any non-deferToVerify Allow + // rule matches the userScope — the qualifier can be a verified + // Web Bot Auth agent, an IP CIDR, a JA4 fingerprint, a UA + // substring, an ASN, a country, or any combination. Web Bot Auth + // is one of the ways to qualify, not the only one. + // + // Skips decrypt/detect/decision-machine entirely, mints an + // authenticated session with `serverChecked: false`, and the + // operator's `/client/authenticated/verify` call is what marks + // it consumed. deferToVerify policies are ignored here for the + // same reason the ordinary flow ignores them at frictionless + // entry — they enforce at verify time only. + // + // A Block or Restrict policy on the same match set always wins + // (severity outranks Allow) so an operator who wrote both + // "allow /24" and "block 10.0.0.5" gets what they asked for. + // getPrioritisedAccessPolicies returns policies in matched-order, + // so the presence of a blocking policy short-circuits the check. + const blockingPolicy = accessPolicies.find( + (p) => + !p.deferToVerify && + (p.type === AccessPolicyType.Block || + p.type === AccessPolicyType.Restrict), + ); + const allowingPolicy = blockingPolicy + ? undefined + : accessPolicies.find( + (p) => !p.deferToVerify && p.type === AccessPolicyType.Allow, + ); + if (allowingPolicy) { + const authenticatedSession = + await tasks.frictionlessManager.createAuthenticatedSession( + token, + ipAddress, + // May be empty string when the Allow was matched by IP / + // JA4 / UA instead of webBotAuthAgent. The session field + // stays unset in that case so verify-side observability + // distinguishes "verified signer" from "trusted IP". + verifiedSignerUrl ?? "", + dapp, + userSitekeyIpHash, + flatHeaders, + req.ipInfo && "isValid" in req.ipInfo && req.ipInfo.isValid + ? req.ipInfo + : undefined, + ); + req.logger.info(() => ({ + msg: "Frictionless decision", + data: { + decision: "authenticated_allow_rule", + captchaType: CaptchaType.authenticated, + sessionId: authenticatedSession.sessionId, + webBotAuthAgent: verifiedSignerUrl, + ruleType: allowingPolicy.description, + }, + })); + recordFrictionlessDecision("authenticated_allow_rule"); + attachHoneypot(res, clientRecord); + return res.json({ + [ApiParams.captchaType]: CaptchaType.authenticated, + [ApiParams.sessionId]: authenticatedSession.sessionId, + [ApiParams.status]: "ok", + dns_url: buildDnsEventUrl(authenticatedSession.sessionId), + ...(verifiedSignerUrl && { agent: verifiedSignerUrl }), + }); + } + const { baseBotScore: rawBaseBotScore, timestamp: rawTimestamp, diff --git a/packages/provider/src/api/verify.ts b/packages/provider/src/api/verify.ts index 96817c80ac..fac18913fe 100644 --- a/packages/provider/src/api/verify.ts +++ b/packages/provider/src/api/verify.ts @@ -571,6 +571,115 @@ export function prosopoVerifyRouter(env: ProviderEnvironment): Router { }, ); + /** + * Verify a Web Bot Auth authenticated session token. Enforces IP binding: + * the operator MUST forward `ip` in the request body, and it must equal + * the client IP captured on the session at frictionless issuance. Only + * tokens minted with captchaType=authenticated are accepted here; a + * regular image/pow/puzzle token routed to this endpoint fails with + * INCORRECT_CAPTCHA_TYPE. + */ + router.post( + ClientApiPaths.VerifyAuthenticatedSession, + async (req, res, next) => { + if (getMaintenanceMode()) { + const verificationResponse: VerificationResponse = + buildMaintenanceVerificationResponse(req.i18n.t); + return res.json(verificationResponse); + } + + let parsed: VerifySolutionBodyTypeOutput; + try { + parsed = VerifySolutionBody.parse(req.body); + } catch (err) { + return next( + new ProsopoApiError("CAPTCHA.PARSE_ERROR", { + context: { code: 400, error: err, body: req.body }, + i18n: req.i18n, + logger: req.logger, + }), + ); + } + + const { dappSignature, token, ip } = parsed; + try { + const { + user, + dapp, + timestamp, + // commitmentId carries the authenticated session's sessionId + // (the widget encodes it there since the ProcaptchaToken schema + // has no dedicated sessionId slot). This is why we don't need + // a token codec change to ship the authenticated flow. + commitmentId: sessionId, + providerUrl, + } = decodeProcaptchaOutput(token); + + const testVerdict = resolveTestSiteKeyVerdict(dapp, req.logger); + if (testVerdict !== null) { + return res.json({ status: "ok", verified: testVerdict }); + } + + const forwarded = await forwardVerifyIfNotIssuer({ + env, + logger: req.logger, + path: ClientApiPaths.VerifyAuthenticatedSession, + providerUrl, + dapp, + user, + body: parsed, + alreadyForwarded: req.headers[VERIFY_FORWARDED_HEADER] !== undefined, + }); + if (forwarded) return res.json(forwarded); + + const tasks = new Tasks(env, req.logger); + validateAddress(dapp, false, 42); + validateAddress(user, false, 42); + + const clientRecord = await tasks.db.getClientRecord(dapp); + if (!clientRecord) { + return next( + new ProsopoApiError("API.SITE_KEY_NOT_REGISTERED", { + context: { code: 400, siteKey: dapp, user }, + i18n: req.i18n, + logger: req.logger, + }), + ); + } + + const keyPair = env.keyring.addFromAddress(dapp); + verifySignature(dappSignature, timestamp.toString(), keyPair); + + if (!sessionId) { + return res.json({ + status: "API.USER_NOT_VERIFIED_NO_SOLUTION", + verified: false, + }); + } + + const outcome = + await tasks.frictionlessManager.verifyAuthenticatedSession( + sessionId, + ip, + ); + res.json(outcome); + } catch (err) { + req.logger.error(() => ({ + err, + msg: "Error in verifyAuthenticatedSession", + data: { body: req.body }, + })); + return next( + new ProsopoApiError("API.BAD_REQUEST", { + context: { code: 500, siteKey: req.body.dapp, user: req.body.user }, + i18n: req.i18n, + logger: req.logger, + }), + ); + } + }, + ); + // Your error handler should always be at the end of your application stack. Apparently it means not only after all // app.use() but also after all your app.get() and app.post() calls. // https://stackoverflow.com/a/62358794/1178971 diff --git a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts index f6c4b5957b..6c39b470f2 100644 --- a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts +++ b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts @@ -42,6 +42,7 @@ import { buildAllWindowIncrements, } from "../../util/usageCounters.js"; import { CaptchaManager } from "../captchaManager.js"; +import { ipMatchesSession } from "./ipMatch.js"; import { DecisionMachineRunner } from "../decisionMachine/decisionMachineRunner.js"; import { getBotScore } from "../detection/getBotScore.js"; import { downgradePuzzleIfUnavailable } from "../puzzle/puzzleRenderer.js"; @@ -342,6 +343,120 @@ export class FrictionlessManager extends CaptchaManager { return sessionRecord; } + /** + * Dedicated issuance path for Web Bot Auth verified requests. The signature + * verification already carried the trust decision; there is no captcha to + * solve, no bot score to compute, no routing to run. The session is minted + * with `captchaType: authenticated`, `agent: true`, `webBotAuthAgent` set to + * the verified Signature-Agent URL, and `ipAddress` frozen for the verify- + * time IP-binding check. Consumed only by `/client/authenticated/verify`. + */ + async createAuthenticatedSession( + token: string, + ipAddress: CompositeIpAddress, + webBotAuthAgent: string, + siteKey: string, + userSitekeyIpHash?: string, + headers?: RequestHeaders, + ipInfo?: IPInfoResponse, + ): Promise { + const sessionRecord: Session = { + sessionId: `${getSessionIDPrefix(this.config.host)}-${uuidv4()}`, + createdAt: new Date(), + token, + // score / threshold are meaningless for a pre-verified pass; zero + // them so downstream analytics never mistake the session for a + // scored one. + score: 0, + threshold: 0, + scoreComponents: { baseScore: 0 }, + ipAddress, + captchaType: CaptchaType.authenticated, + userSitekeyIpHash, + webView: false, + iFrame: false, + decryptedHeadHash: "", + siteKey, + agent: true, + webBotAuthAgent, + ...(ipInfo && { ipInfo }), + ...(headers && { headers }), + }; + + await this.db.storeSessionRecord(sessionRecord); + + if (this.writeQueue) { + const cacheData = sessionRecord as unknown as Record; + const cachePromises: Promise[] = [ + this.writeQueue.cacheSession(sessionRecord.sessionId, cacheData), + ]; + if (userSitekeyIpHash) { + cachePromises.push( + this.writeQueue.cacheSessionByHash( + userSitekeyIpHash, + sessionRecord.sessionId, + ), + ); + } + await Promise.all(cachePromises).catch(() => {}); + } + + return sessionRecord; + } + + /** + * Verify an authenticated (Web Bot Auth) session. Called from + * `/client/authenticated/verify` after the operator forwards their + * dApp-signed token. Enforces the four properties that make replay + * infeasible: + * 1. session exists and was minted with captchaType=authenticated + * (so ordinary captcha tokens can't be redeemed here) + * 2. session hasn't been consumed (serverChecked === false) + * 3. operator forwarded the client IP (`ip` is required — silently + * dropping the check would nullify the whole binding) + * 4. the forwarded IP matches the IP the session was issued to + * + * Marks serverChecked=true on success so subsequent verifies fail loudly. + */ + async verifyAuthenticatedSession( + sessionId: string, + ip: string | undefined, + ): Promise<{ verified: boolean; status: string }> { + if (!ip) { + return { + verified: false, + status: "API.AUTHENTICATED_IP_REQUIRED", + }; + } + const session = await this.db.getSessionRecordBySessionId(sessionId); + if (!session) { + return { + verified: false, + status: "API.USER_NOT_VERIFIED_NO_SOLUTION", + }; + } + if (session.captchaType !== CaptchaType.authenticated) { + return { + verified: false, + status: "API.INCORRECT_CAPTCHA_TYPE", + }; + } + if (session.serverChecked) { + return { + verified: false, + status: "API.USER_ALREADY_VERIFIED", + }; + } + if (!ipMatchesSession(ip, session.ipAddress)) { + return { + verified: false, + status: "API.AUTHENTICATED_IP_MISMATCH", + }; + } + await this.db.updateSessionRecord(sessionId, { serverChecked: true }); + return { verified: true, status: "API.USER_VERIFIED" }; + } + async sendImageCaptcha( params?: Partial, ): Promise { diff --git a/packages/provider/src/tasks/frictionless/ipMatch.ts b/packages/provider/src/tasks/frictionless/ipMatch.ts new file mode 100644 index 0000000000..349ae305ba --- /dev/null +++ b/packages/provider/src/tasks/frictionless/ipMatch.ts @@ -0,0 +1,40 @@ +// 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. + +import type { CompositeIpAddress } from "@prosopo/types"; +import { getCompositeIpAddress } from "../../compositeIpAddress.js"; + +/** + * Compare an operator-supplied plain-string IP against the CompositeIpAddress + * captured on a session. Returns true iff the parsed IP has the same type + * (v4/v6) and numeric halves. + * + * Load-bearing for Web Bot Auth IP binding — a leaked authenticated token + * replayed from a different IP fails here. Malformed operator IPs degrade + * to {lower: 0n, type: v4} inside getCompositeIpAddress and can never match + * a real session (a real v4 session has a non-zero `lower`; a real v6 has + * type=v6). That degradation is deliberate: silently treating garbage as a + * match would nullify the whole binding. + */ +export const ipMatchesSession = ( + operatorIp: string, + sessionIp: CompositeIpAddress, +): boolean => { + const parsed = getCompositeIpAddress(operatorIp); + return ( + parsed.type === sessionIp.type && + parsed.lower === sessionIp.lower && + (parsed.upper ?? undefined) === (sessionIp.upper ?? undefined) + ); +}; diff --git a/packages/provider/src/tasks/spam/checkTrafficFilter.ts b/packages/provider/src/tasks/spam/checkTrafficFilter.ts index b32e17c378..bc94144d89 100644 --- a/packages/provider/src/tasks/spam/checkTrafficFilter.ts +++ b/packages/provider/src/tasks/spam/checkTrafficFilter.ts @@ -307,6 +307,9 @@ const CAPTCHA_TYPE_RANK: Record = { [CaptchaType.puzzle]: 3, [CaptchaType.pow]: 2, [CaptchaType.frictionless]: 1, + // authenticated is Web Bot Auth pass-through, never a challenge outcome + // selected by traffic filtering — include for enum totality only. + [CaptchaType.authenticated]: 0, }; const rankCaptchaType = (t: CaptchaType | undefined): number => diff --git a/packages/provider/src/tests/unit/tasks/frictionless/ipMatch.unit.test.ts b/packages/provider/src/tests/unit/tasks/frictionless/ipMatch.unit.test.ts new file mode 100644 index 0000000000..09c8ef4f8a --- /dev/null +++ b/packages/provider/src/tests/unit/tasks/frictionless/ipMatch.unit.test.ts @@ -0,0 +1,122 @@ +// 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. + +import { type CompositeIpAddress, IpAddressType } from "@prosopo/types"; +import { describe, expect, it } from "vitest"; +import { getCompositeIpAddress } from "../../../../compositeIpAddress.js"; +import { ipMatchesSession } from "../../../../tasks/frictionless/ipMatch.js"; + +// IP-binding is the load-bearing replay defence for Web Bot Auth +// authenticated sessions. If this comparison silently accepts a mismatch +// (empty string, garbage, wrong-version IP, off-by-one bigint), a leaked +// token becomes a bearer credential. The failure mode is silent — so the +// tests here are worth their weight in outages avoided. + +const composeV4 = (ip: string): CompositeIpAddress => getCompositeIpAddress(ip); +const composeV6 = (ip: string): CompositeIpAddress => getCompositeIpAddress(ip); + +describe("ipMatchesSession", () => { + describe("IPv4", () => { + it("matches identical v4 addresses", () => { + const session = composeV4("192.0.2.42"); + expect(ipMatchesSession("192.0.2.42", session)).toBe(true); + }); + + it("rejects off-by-one v4 addresses", () => { + const session = composeV4("192.0.2.42"); + expect(ipMatchesSession("192.0.2.43", session)).toBe(false); + }); + + it("rejects the /24 neighbour", () => { + const session = composeV4("192.0.2.42"); + expect(ipMatchesSession("192.0.3.42", session)).toBe(false); + }); + }); + + describe("IPv6", () => { + it("matches identical v6 addresses", () => { + const session = composeV6("2001:db8::1"); + expect(ipMatchesSession("2001:db8::1", session)).toBe(true); + }); + + it("matches equivalent v6 canonicalisations", () => { + const session = composeV6("2001:db8:0:0:0:0:0:1"); + // Compressed form of the same address. + expect(ipMatchesSession("2001:db8::1", session)).toBe(true); + }); + + it("rejects sibling v6 addresses", () => { + const session = composeV6("2001:db8::1"); + expect(ipMatchesSession("2001:db8::2", session)).toBe(false); + }); + + it("rejects v6 addresses that only differ in the upper half", () => { + // Same lower 64 bits, different upper 64 bits — would slip through + // a lower-only comparison. This is why the function checks both. + const session = composeV6("2001:db8::1"); + expect(ipMatchesSession("2001:db9::1", session)).toBe(false); + }); + }); + + describe("Cross-family", () => { + it("rejects a v4 operator IP against a v6 session", () => { + const session = composeV6("2001:db8::1"); + expect(ipMatchesSession("192.0.2.42", session)).toBe(false); + }); + + it("rejects a v6 operator IP against a v4 session", () => { + const session = composeV4("192.0.2.42"); + expect(ipMatchesSession("2001:db8::1", session)).toBe(false); + }); + }); + + describe("Malformed input", () => { + it("rejects an empty operator IP against any real session", () => { + expect(ipMatchesSession("", composeV4("192.0.2.42"))).toBe(false); + expect(ipMatchesSession("", composeV6("2001:db8::1"))).toBe(false); + }); + + it("rejects garbage operator IP against any real session", () => { + expect(ipMatchesSession("not.an.ip", composeV4("192.0.2.42"))).toBe( + false, + ); + expect(ipMatchesSession("g:h:i", composeV6("2001:db8::1"))).toBe(false); + }); + + it("rejects the malformed-IP sentinel against a real v4 session", () => { + // getCompositeIpAddress returns {lower: 0n, type: v4} for garbage. + // A REAL v4 session with lower=0n would be 0.0.0.0 — never a + // legitimate client IP in production. Guard against the corner + // anyway by asserting the sentinel doesn't match a normal address. + const realSession = composeV4("1.1.1.1"); + expect(ipMatchesSession("", realSession)).toBe(false); + }); + }); + + describe("Regression: the sentinel-v4 corner case", () => { + it("sentinel {lower: 0n, type: v4} matches 0.0.0.0 (documented corner)", () => { + // Documented: getCompositeIpAddress degrades to {lower: 0n, type: v4} + // on parse failure. A session that was somehow issued with 0.0.0.0 + // would collide with any garbage operator IP. Not a realistic path + // (real client IPs aren't 0.0.0.0) but recorded here so a future + // change of the degradation sentinel is caught by test drift. + const zeroSession: CompositeIpAddress = { + lower: 0n, + type: IpAddressType.v4, + }; + expect(ipMatchesSession("", zeroSession)).toBe(true); + expect(ipMatchesSession("not.an.ip", zeroSession)).toBe(true); + }); + }); +}); diff --git a/packages/provider/src/util.ts b/packages/provider/src/util.ts index e2c452c3d7..02249e36c9 100644 --- a/packages/provider/src/util.ts +++ b/packages/provider/src/util.ts @@ -74,7 +74,10 @@ export async function checkIfTaskIsRunning( // TODO: This is a temporary fix to prevent failed tasks from blocking the next task if (runningTask && runningTask.datetime.getTime() > twoMinutesAgo) { const completedTask = await db.getScheduledTaskStatus( - runningTask._id, + // Mongoose 8's Document._id defaults to `unknown`; the schema stores + // an ObjectId, and the sibling API expects one. Narrow at the call + // site rather than annotating every ScheduledTaskRecord consumer. + runningTask._id as import("mongoose").Types.ObjectId, ScheduledTaskStatus.Completed, ); return !completedTask; diff --git a/packages/provider/tsconfig.cjs.json b/packages/provider/tsconfig.cjs.json index 8a742185a7..4043ad2c31 100644 --- a/packages/provider/tsconfig.cjs.json +++ b/packages/provider/tsconfig.cjs.json @@ -65,6 +65,9 @@ { "path": "../user-access-policy/tsconfig.cjs.json" }, + { + "path": "../web-bot-auth/tsconfig.cjs.json" + }, { "path": "../api/tsconfig.cjs.json" }, diff --git a/packages/provider/tsconfig.json b/packages/provider/tsconfig.json index 013910e69d..fb6a150fd8 100644 --- a/packages/provider/tsconfig.json +++ b/packages/provider/tsconfig.json @@ -71,6 +71,9 @@ { "path": "../user-access-policy" }, + { + "path": "../web-bot-auth" + }, { "path": "../api" }, diff --git a/packages/types-database/src/types/provider.ts b/packages/types-database/src/types/provider.ts index f73692ba91..ce8ac90cc9 100644 --- a/packages/types-database/src/types/provider.ts +++ b/packages/types-database/src/types/provider.ts @@ -790,6 +790,13 @@ export const SessionRecordSchema = new Schema({ }, userSubmitted: { type: Boolean, required: false }, serverChecked: { type: Boolean, required: false }, + // Web Bot Auth: true on sessions issued to a verified Ed25519 signer. + // Boolean shortcut; the full Signature-Agent URL is on webBotAuthAgent. + agent: { type: Boolean, required: false }, + // Verified Signature-Agent URL (e.g. "https://chatgpt.com"). Presence + // on a session makes the `/verify` path require the operator to pass + // `ip` and enforces `session.ipAddress === ip` for replay defence. + webBotAuthAgent: { type: String, required: false }, // WASM SIMD CPU fingerprint readings collected by the catcher client. // Stored as a free-form Mixed sub-document because the shape is a // discriminated union and the dataset is still evolving — Zod validates diff --git a/packages/types/src/client/captchaType/captchaType.ts b/packages/types/src/client/captchaType/captchaType.ts index 82fa2f2ca4..8ebc716a9b 100644 --- a/packages/types/src/client/captchaType/captchaType.ts +++ b/packages/types/src/client/captchaType/captchaType.ts @@ -19,11 +19,21 @@ enum CaptchaType { pow = "pow", frictionless = "frictionless", puzzle = "puzzle", + // Web Bot Auth verified — no user-facing challenge. Issued only by the + // frictionless flow when the request carried a valid Ed25519 signature + // per RFC 9421 / draft-meunier-web-bot-auth AND no operator-authored + // Block/Restrict rule matched the verified Signature-Agent URL. The + // widget renders a "Verified agent" badge and auto-submits the token. + // The captcha record carries `webBotAuthAgent` + `clientIp` so the + // verify path can enforce IP binding. + authenticated = "authenticated", } const CaptchaTypeSchema = z.nativeEnum(CaptchaType); -// Decision machines only work with pow and image captcha types (not frictionless) +// Decision machines only work with pow, image and puzzle captcha types. +// Frictionless is the outer flow that dispatches to these; authenticated +// is a pre-verified pass-through and has no scoring surface. const DecisionMachineCaptchaTypeSchema = z.union([ z.literal(CaptchaType.pow), z.literal(CaptchaType.image), diff --git a/packages/types/src/client/settings.ts b/packages/types/src/client/settings.ts index 0cc71b0bfe..1cf8ac150c 100644 --- a/packages/types/src/client/settings.ts +++ b/packages/types/src/client/settings.ts @@ -427,6 +427,17 @@ export const ClientSettingsSchema = object({ // whether the submitted emails are mostly spam). storeMetadata: boolean().optional(), honeypot: HoneypotSettingsSchema.optional(), + // Web Bot Auth (RFC 9421) verified-agent pass-through. When true, a + // request that carries a valid Ed25519 signature and matches no + // operator-authored Block/Restrict rule on its Signature-Agent URL + // gets an `authenticated` session — no captcha, no interaction. When + // false (default), verified agents are still identified on the userScope + // so per-agent access rules can act on them, but they follow the normal + // challenge flow like everyone else. Opt-in because the whole + // authenticated flow issues bearer tokens that skip the puzzle-solve + // cost, and the site operator should make that trust decision + // explicitly rather than get it as a default. + allowAgents: boolean().optional(), }); export type IUserSettings = output; diff --git a/packages/types/src/procaptcha/props.ts b/packages/types/src/procaptcha/props.ts index 751d58b4b6..1305a1adb1 100644 --- a/packages/types/src/procaptcha/props.ts +++ b/packages/types/src/procaptcha/props.ts @@ -81,6 +81,11 @@ export type FrictionlessState = { // bot fills it, the value is sent back as `clientMetaData.hp` on // solution submission. Undefined when honeypot is disabled for the site. hp?: string; + // Canonical Signature-Agent URL from the /frictionless response when + // the request was Web Bot Auth verified. Only present when the response + // carried `captchaType: authenticated`; consumed by the badge widget so + // the operator can see which agent verified (e.g. "chatgpt.com"). + agent?: string; }; export type ProcaptchaCallbacks = Partial; diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index cc28948685..3411955962 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -90,6 +90,11 @@ export enum ClientApiPaths { GetPuzzleCaptchaChallenge = "/v1/prosopo/provider/client/captcha/puzzle", SubmitPuzzleCaptchaSolution = "/v1/prosopo/provider/client/puzzle/solution", VerifyPuzzleCaptchaSolution = "/v1/prosopo/provider/client/puzzle/verify", + // Verify path for Web Bot Auth authenticated sessions. Only accepts tokens + // minted with captchaType=authenticated. Requires the operator to forward + // the client IP so the session's `ipAddress` binding can be enforced; + // a leaked authenticated token cannot be replayed from a different IP. + VerifyAuthenticatedSession = "/v1/prosopo/provider/client/authenticated/verify", GetProviderStatus = "/v1/prosopo/provider/client/status", SubmitUserEvents = "/v1/prosopo/provider/client/events", CheckSpamEmail = "/v1/prosopo/provider/client/spam/email", @@ -186,6 +191,10 @@ export const ProviderDefaultRateLimits = { windowMs: 60000, limit: 15000, }, + [ClientApiPaths.VerifyAuthenticatedSession]: { + windowMs: 60000, + limit: 15000, + }, [ClientApiPaths.GetProviderStatus]: { windowMs: 60000, limit: 60 }, [ClientApiPaths.CheckSpamEmail]: { windowMs: 60000, limit: 60 }, [ClientApiPaths.AssignDetectorBundle]: { windowMs: 60000, limit: 60 }, @@ -449,7 +458,8 @@ export interface GetFrictionlessCaptchaResponse extends ApiResponse { [ApiParams.captchaType]: | CaptchaType.pow | CaptchaType.image - | CaptchaType.puzzle; + | CaptchaType.puzzle + | CaptchaType.authenticated; [ApiParams.sessionId]?: string; // Encoded honeypot question. NOT serialised by the provider on the wire // (it travels in the `x-prosopo-meta` response header so it doesn't sit @@ -459,6 +469,10 @@ export interface GetFrictionlessCaptchaResponse extends ApiResponse { [ApiParams.hp]?: string; // Per-session DNS observation URL; undefined when no dns sidecar. dns_url?: string; + // Web Bot Auth: canonical Signature-Agent URL of the verified signer. + // Only present when captchaType === "authenticated". Rendered by the + // widget's badge so the operator can see WHICH agent verified. + agent?: string; } export interface PowCaptchaSolutionEscalation { diff --git a/packages/types/src/provider/database.ts b/packages/types/src/provider/database.ts index 3d4c332dd0..3c6bf914e8 100644 --- a/packages/types/src/provider/database.ts +++ b/packages/types/src/provider/database.ts @@ -621,6 +621,17 @@ export type Session = { }; userSubmitted?: boolean; serverChecked?: boolean; + // True on sessions issued because the request was Web Bot Auth verified + // (captchaType === CaptchaType.authenticated). Boolean shortcut for the + // Traffic view's "pre-verified pass" filter; the full signer URL lives + // on `webBotAuthAgent`. + agent?: boolean; + // Canonical Signature-Agent URL (e.g. "https://chatgpt.com") captured + // from the verified Ed25519 signature at issuance. Read at + // `/verify` time to enforce IP binding: `ipAddress` on the session + // must equal the `ip` the operator forwards on the verify call, so a + // leaked authenticated token can't be replayed from a different IP. + webBotAuthAgent?: string; // WASM SIMD CPU fingerprint readings forwarded by the catcher client. simdReadings?: SimdReadings; // Stage at which the readings first arrived. diff --git a/packages/types/src/provider/matchedAccessRule.ts b/packages/types/src/provider/matchedAccessRule.ts index 954ae9ccd7..d31346a743 100644 --- a/packages/types/src/provider/matchedAccessRule.ts +++ b/packages/types/src/provider/matchedAccessRule.ts @@ -52,7 +52,7 @@ export type MatchedRuleCondition = { */ export const MatchedAccessRuleSchema = object({ ruleHash: string(), - policyType: union([literal("block"), literal("restrict")]), + policyType: union([literal("block"), literal("restrict"), literal("allow")]), conditions: array(MatchedRuleConditionSchema), description: string().optional(), captchaType: CaptchaTypeSchema.optional(), @@ -64,7 +64,9 @@ export const MatchedAccessRuleSchema = object({ export type MatchedAccessRule = { ruleHash: string; - policyType: "block" | "restrict"; + // "allow" fires the authenticated-session fast-path in the frictionless + // flow — see AccessPolicyType in @prosopo/user-access-policy. + policyType: "block" | "restrict" | "allow"; conditions: MatchedRuleCondition[]; description?: string; captchaType?: CaptchaType; diff --git a/packages/user-access-policy/src/mongoose/mongooseRuleSchema.ts b/packages/user-access-policy/src/mongoose/mongooseRuleSchema.ts index 44723527aa..9259311a79 100644 --- a/packages/user-access-policy/src/mongoose/mongooseRuleSchema.ts +++ b/packages/user-access-policy/src/mongoose/mongooseRuleSchema.ts @@ -32,6 +32,7 @@ const userAttributesSchema: SchemaDefinition = { countryCode: { type: String, required: false }, asn: { type: Number, required: false }, os: { type: String, required: false }, + webBotAuthAgent: { type: String, required: false }, } satisfies AllKeys; const userIpSchema: SchemaDefinition = { diff --git a/packages/user-access-policy/src/redis/redisRuleIndex.ts b/packages/user-access-policy/src/redis/redisRuleIndex.ts index 14b0347d55..0b39f48438 100644 --- a/packages/user-access-policy/src/redis/redisRuleIndex.ts +++ b/packages/user-access-policy/src/redis/redisRuleIndex.ts @@ -41,6 +41,7 @@ export const userAttributesRedisSchema: RediSearchSchema = { countryCode: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true }, asn: { type: SCHEMA_FIELD_TYPE.NUMERIC, INDEXMISSING: true }, os: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true }, + webBotAuthAgent: { type: SCHEMA_FIELD_TYPE.TAG, INDEXMISSING: true }, } satisfies AllKeys; export const userScopeRedisSchema: RediSearchSchema = { diff --git a/packages/user-access-policy/src/rule.ts b/packages/user-access-policy/src/rule.ts index 220afebba2..0bc41e9162 100644 --- a/packages/user-access-policy/src/rule.ts +++ b/packages/user-access-policy/src/rule.ts @@ -16,6 +16,15 @@ import type { CaptchaType } from "@prosopo/types"; export enum AccessPolicyType { Block = "block", Restrict = "restrict", + // Explicit allow-list: request that matches an Allow rule bypasses the + // challenge flow and gets an `authenticated` session (captchaType = + // authenticated on the frictionless response). Fires for any qualifying + // scope — verified Web Bot Auth agent, allow-listed IP, ja4/ja4_and_ip + // match, UA substring, ASN, country. IP-binding on the resulting + // session token defends against replay from a different IP. Opt-in per + // rule; the operator authors an Allow rule the same way they'd author + // a Block or Restrict rule. + Allow = "allow", } // Sentinel stamped on the Redis `clientId` field for rules that would @@ -78,6 +87,13 @@ export type UserAttributes = { // drop/limit requests from a given OS even when the client omits client // hints. os?: string; + // Canonical Web Bot Auth Signature-Agent URL — e.g. + // "https://signatures.openai.com/". Matched at runtime against the + // `Signature-Agent` header after RFC 9421 Ed25519 verification succeeds. + // Stored as-is like `countryCode` (exact equality after normalisation). + // Rules with this field only match requests carrying a valid Web Bot + // Auth signature; unverified traffic falls through. + webBotAuthAgent?: string; }; export type UserScope = UserAttributes & UserIp; diff --git a/packages/user-access-policy/src/ruleInput/userScopeInput.ts b/packages/user-access-policy/src/ruleInput/userScopeInput.ts index d3e1a01fb2..7189e9602c 100644 --- a/packages/user-access-policy/src/ruleInput/userScopeInput.ts +++ b/packages/user-access-policy/src/ruleInput/userScopeInput.ts @@ -22,6 +22,18 @@ import type { UserAttributesRecord, UserIpRecord } from "#policy/ruleRecord.js"; export type UserAttributesInput = UserAttributes & UserAttributesRecord; +// Cloudflare's Web Bot Auth reference implementation compares Signature-Agent +// values as URL objects, which are case-insensitive on scheme/host but +// case-sensitive on path. Store the canonical form so runtime matcher can use +// plain string equality. +function normaliseSignatureAgentUrl(raw: string): string { + const url = new URL(raw); + url.hostname = url.hostname.toLowerCase(); + url.protocol = url.protocol.toLowerCase(); + if (url.pathname === "/") url.pathname = ""; + return url.toString().replace(/\/$/, ""); +} + const userAttributesSchema = z.object({ // coerce is used for safety, as e.g., incoming userId can be digital userId: z.coerce.string().optional(), @@ -36,6 +48,15 @@ const userAttributesSchema = z.object({ // countryCode: a stale/unknown value just never matches a request rather // than failing the whole rule parse. os: z.coerce.string().optional(), + // Web Bot Auth Signature-Agent URL. Normalised at parse time + // (lowercase scheme+host, no trailing slash) so the runtime matcher can + // use exact string equality against the verified `Signature-Agent` + // header value. + webBotAuthAgent: z.coerce + .string() + .url() + .transform(normaliseSignatureAgentUrl) + .optional(), } satisfies AllKeys) satisfies ZodType; const userAttributesInput = z diff --git a/packages/user-access-policy/src/ruleRecord.ts b/packages/user-access-policy/src/ruleRecord.ts index 523758f37d..01326dc78f 100644 --- a/packages/user-access-policy/src/ruleRecord.ts +++ b/packages/user-access-policy/src/ruleRecord.ts @@ -32,6 +32,7 @@ export const userAttributesRecordFields = [ "countryCode", "asn", "os", + "webBotAuthAgent", ] as const satisfies (keyof UserAttributesRecord)[]; export type UserIpRecord = { diff --git a/packages/user-access-policy/src/tests/transformRule.unit.test.ts b/packages/user-access-policy/src/tests/transformRule.unit.test.ts index 0c7527b492..4cda4a64dd 100644 --- a/packages/user-access-policy/src/tests/transformRule.unit.test.ts +++ b/packages/user-access-policy/src/tests/transformRule.unit.test.ts @@ -116,6 +116,7 @@ describe("transformRule", () => { countryCode: "US", asn: 205016, os: "macos", + webBotAuthAgent: "https://signatures.openai.com", } satisfies AccessRule; it("should transform access rule record into rule", () => { diff --git a/packages/web-bot-auth/package.json b/packages/web-bot-auth/package.json new file mode 100644 index 0000000000..80df93a4ba --- /dev/null +++ b/packages/web-bot-auth/package.json @@ -0,0 +1,48 @@ +{ + "name": "@prosopo/web-bot-auth", + "version": "0.0.1", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/cjs/index.cjs" + } + }, + "author": "PROSOPO LIMITED ", + "license": "Apache-2.0", + "private": false, + "engines": { + "node": "^24", + "npm": "^11" + }, + "scripts": { + "clean": "del-cli --verbose dist tsconfig.tsbuildinfo", + "build": "npm run build:cross-env -- --mode ${NODE_ENV:-development}", + "build:cross-env": "vite build --config vite.esm.config.ts", + "build:tsc": "tsc --build --verbose", + "build:cjs": "NODE_ENV=${NODE_ENV:-development}; vite build --config vite.cjs.config.ts --mode $NODE_ENV", + "typecheck": "tsc --project tsconfig.types.json", + "test": "NODE_ENV=${NODE_ENV:-test}; npx vitest run --config ./vite.test.config.ts" + }, + "type": "module", + "dependencies": { + "@noble/curves": "1.9.2" + }, + "devDependencies": { + "@prosopo/config": "3.3.11", + "@types/node": "22.10.2", + "@vitest/coverage-v8": "4.1.10", + "del-cli": "6.0.0", + "typescript": "5.6.2", + "vite": "8.1.5", + "vitest": "4.1.10" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/prosopo/captcha.git", + "directory": "packages/web-bot-auth" + }, + "sideEffects": false +} diff --git a/packages/web-bot-auth/src/base64.ts b/packages/web-bot-auth/src/base64.ts new file mode 100644 index 0000000000..2df7d36004 --- /dev/null +++ b/packages/web-bot-auth/src/base64.ts @@ -0,0 +1,39 @@ +// 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. + +// Portable base64 / base64url decoders that work in Node 18+ and browsers +// without pulling a dep. Ed25519 JWK `x` fields ship base64url; the +// Signature header ships plain base64 wrapped in colons. + +const padTo4 = (s: string): string => { + const pad = (4 - (s.length % 4)) % 4; + return s + "=".repeat(pad); +}; + +const binaryToBytes = (bin: string): Uint8Array => { + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +}; + +export const decodeBase64 = (input: string): Uint8Array => { + // `atob` throws on invalid characters; callers turn that into a + // verification failure rather than a thrown error crossing the boundary. + return binaryToBytes(atob(input)); +}; + +export const decodeBase64Url = (input: string): Uint8Array => { + const b64 = padTo4(input.replace(/-/g, "+").replace(/_/g, "/")); + return binaryToBytes(atob(b64)); +}; diff --git a/packages/web-bot-auth/src/index.ts b/packages/web-bot-auth/src/index.ts new file mode 100644 index 0000000000..3b436190ec --- /dev/null +++ b/packages/web-bot-auth/src/index.ts @@ -0,0 +1,47 @@ +// 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. + +// Web Bot Auth (RFC 9421 HTTP Message Signatures, draft-meunier-web-bot-auth) +// verifier for Prosopo. Accepts both the bare-quoted-string Signature-Agent +// header form (OpenAI) and the RFC 8941 dictionary form (Google). Fetches +// the signer's JWKS from the well-known directory and caches per Cache-Control +// (1 h default fallback). + +export { + verifyWebBotAuth, + type VerifyResult, + type VerifyFailReason, + type VerifiableRequest, +} from "./verify.js"; +export { + resolveJwksFromSignatureAgent, + clearJwksCache, + type Jwk, + type JwksResolverOptions, + type JwksFetch, +} from "./jwksResolver.js"; +export { + parseSignatureAgentHeader, + normaliseSignatureAgentUrl, +} from "./parseSignatureAgent.js"; +export { + parseSignatureInput, + parseSignature, + type SignatureInputEntry, + type ParamValue, +} from "./structuredFields.js"; +export { + buildSignatureBase, + type SignatureBaseInput, +} from "./signatureBase.js"; diff --git a/packages/web-bot-auth/src/jwksResolver.ts b/packages/web-bot-auth/src/jwksResolver.ts new file mode 100644 index 0000000000..814be67e22 --- /dev/null +++ b/packages/web-bot-auth/src/jwksResolver.ts @@ -0,0 +1,89 @@ +// 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. + +// Every Signature-Agent URL points to a well-known JWKS directory. Fetch on +// first use, cache for the duration the origin recommends (or a 1 h default), +// and refresh on cache miss / expiry. Not persisted — an idle process +// forgets, which is fine: the fetch cost is one round-trip per hour per +// signer, and stale keys are worse than an extra fetch. + +export type Jwk = { + kty: string; + kid?: string; + crv?: string; + x?: string; + alg?: string; + use?: string; + // Passthrough for anything else the directory adds — Cloudflare's verifier + // consumes JsonWebKey, which is a broad type. + [key: string]: unknown; +}; + +export type JwksFetch = (url: string) => Promise; + +const DIRECTORY_PATH = "/.well-known/http-message-signatures-directory"; +const DEFAULT_TTL_MS = 60 * 60 * 1000; + +type CacheEntry = { keys: Jwk[]; expiresAt: number }; + +const cache = new Map(); + +const parseMaxAge = (header: string | null): number | null => { + if (!header) return null; + const match = /max-age=(\d+)/i.exec(header); + return match?.[1] ? Number(match[1]) * 1000 : null; +}; + +export type JwksResolverOptions = { + // Injectable for tests. Defaults to the global fetch. + fetch?: JwksFetch; + // Overrides the cache-control / default TTL. In milliseconds. + ttlMs?: number; +}; + +export const resolveJwksFromSignatureAgent = async ( + signerUrl: string, + options: JwksResolverOptions = {}, +): Promise => { + const now = Date.now(); + const cached = cache.get(signerUrl); + if (cached && cached.expiresAt > now) return cached.keys; + + const fetchImpl = options.fetch ?? fetch; + const directoryUrl = new URL(DIRECTORY_PATH, `${signerUrl}/`).toString(); + const response = await fetchImpl(directoryUrl); + if (!response.ok) { + throw new Error( + `JWKS fetch ${response.status} at ${directoryUrl}`, + ); + } + + const body = (await response.json()) as { keys?: Jwk[] }; + const keys = Array.isArray(body.keys) ? body.keys : []; + + const ttl = + options.ttlMs ?? + parseMaxAge(response.headers.get("cache-control")) ?? + DEFAULT_TTL_MS; + + cache.set(signerUrl, { keys, expiresAt: now + ttl }); + return keys; +}; + +// Test / long-running-process escape hatch — resets the in-memory cache so +// tests don't leak state between suites and operators can force a refresh +// after publishing a rotated key. +export const clearJwksCache = (): void => { + cache.clear(); +}; diff --git a/packages/web-bot-auth/src/parseSignatureAgent.ts b/packages/web-bot-auth/src/parseSignatureAgent.ts new file mode 100644 index 0000000000..ca8bf34dd5 --- /dev/null +++ b/packages/web-bot-auth/src/parseSignatureAgent.ts @@ -0,0 +1,50 @@ +// 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. + +// Two Signature-Agent header forms are seen in production today: +// +// Bare quoted string (draft ≤ 03, still what OpenAI ships): +// Signature-Agent: "https://chatgpt.com" +// +// Structured Fields dictionary (draft-meunier-web-bot-auth-04+, what Google +// ships as of 2026): +// Signature-Agent: g="https://agent.bot.goog" +// +// The verifier accepts either. When the dictionary form carries multiple +// entries the first one wins; the spec doesn't define ordering and no live +// deployment ships more than one entry yet. + +const BARE_QUOTED = /^"(https?:\/\/[^"]+)"$/; +const DICT_ENTRY = /^\s*[a-zA-Z][a-zA-Z0-9_-]*="(https?:\/\/[^"]+)"/; + +// Canonicalise per Cloudflare's Web Bot Auth reference implementation +// (lowercase scheme + host, no trailing slash) so downstream string equality +// works against `userAttributesInput.webBotAuthAgent`, which normalises the +// same way at rule-authoring time. +export const normaliseSignatureAgentUrl = (raw: string): string => { + const url = new URL(raw); + url.hostname = url.hostname.toLowerCase(); + url.protocol = url.protocol.toLowerCase(); + if (url.pathname === "/") url.pathname = ""; + return url.toString().replace(/\/$/, ""); +}; + +export const parseSignatureAgentHeader = (raw: string): string | null => { + const trimmed = raw.trim(); + const bare = BARE_QUOTED.exec(trimmed); + if (bare?.[1]) return normaliseSignatureAgentUrl(bare[1]); + const dict = DICT_ENTRY.exec(trimmed); + if (dict?.[1]) return normaliseSignatureAgentUrl(dict[1]); + return null; +}; diff --git a/packages/web-bot-auth/src/signatureBase.ts b/packages/web-bot-auth/src/signatureBase.ts new file mode 100644 index 0000000000..cb156ceb82 --- /dev/null +++ b/packages/web-bot-auth/src/signatureBase.ts @@ -0,0 +1,54 @@ +// 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. + +// RFC 9421 §2.3 signature base construction — the string that was signed. +// Web Bot Auth pins the covered-components set to {@authority, +// signature-agent}; the general HTTP Message Signatures spec allows many +// more (@method, @path, @query, content-digest, etc.) but we only implement +// the ones the profile actually uses. Extend the switch when adding +// support for new components. + +export type SignatureBaseInput = { + // Value for `@authority` — RFC 9421 §2.2.4: the request authority + // (host + optional port), lowercased. + authority: string; + // Verbatim `Signature-Agent` header string, quotes intact + // (`"https://chatgpt.com"` or `g="https://agent.bot.goog"`). + // The signer signed the header value; we sign what they signed. + signatureAgent: string; +}; + +export const buildSignatureBase = ( + coveredComponents: string[], + values: SignatureBaseInput, + signatureParamsSerialised: string, +): string => { + const lines: string[] = []; + for (const component of coveredComponents) { + switch (component) { + case "@authority": + lines.push(`"@authority": ${values.authority}`); + break; + case "signature-agent": + lines.push(`"signature-agent": ${values.signatureAgent}`); + break; + default: + throw new Error( + `unsupported covered component "${component}" for Web Bot Auth`, + ); + } + } + lines.push(`"@signature-params": ${signatureParamsSerialised}`); + return lines.join("\n"); +}; diff --git a/packages/web-bot-auth/src/structuredFields.ts b/packages/web-bot-auth/src/structuredFields.ts new file mode 100644 index 0000000000..957f3ce44a --- /dev/null +++ b/packages/web-bot-auth/src/structuredFields.ts @@ -0,0 +1,242 @@ +// 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. + +// Minimal RFC 8941 Structured Fields parser — only the subset needed to +// interpret Web Bot Auth's three headers: +// +// Signature-Input: sig1=("@authority" "signature-agent");created=1;expires=2;keyid="k";alg="ed25519";tag="web-bot-auth" +// Signature: sig1=:base64bytes: +// Signature-Agent: (see parseSignatureAgent.ts) +// +// Web Bot Auth uses a single signature label per request. Multi-signature +// dictionaries are legal in RFC 9421 but outside the profile's scope; the +// parser accepts the first entry and ignores the rest with a note. + +import { decodeBase64 } from "./base64.js"; + +export type ParamValue = string | number | boolean | Uint8Array; + +export type SignatureInputEntry = { + label: string; + // Ordered list of covered component identifiers, quotes stripped + // (e.g. ["@authority", "signature-agent"]). + coveredComponents: string[]; + params: Record; + // The raw "(...);params" substring — reused verbatim as the + // @signature-params value when reconstructing the signature base, so + // the caller doesn't need to re-serialise structured fields. + serialisedValue: string; +}; + +// Find the position of the first non-quote character `ch` outside any +// double-quoted string. RFC 8941 allows backslash-escaping of " and \ inside +// strings; anything else terminates a param entry. Not usable for finding +// `"` itself — quotes toggle state, they never match. Callers looking for a +// closing quote use `indexOf('"', from)` directly, which is safe because +// Web Bot Auth's tokens never contain backslash-escaped quotes. +const findUnquoted = (s: string, ch: string, from: number): number => { + if (ch === '"') { + throw new Error("findUnquoted cannot search for '\"' — use indexOf"); + } + let inString = false; + for (let i = from; i < s.length; i++) { + const c = s[i]; + if (inString) { + if (c === "\\" && i + 1 < s.length) { + i++; + continue; + } + if (c === '"') inString = false; + continue; + } + if (c === '"') { + inString = true; + continue; + } + if (c === ch) return i; + } + return -1; +}; + +const unquote = (raw: string): string => { + if ( + raw.length < 2 || + raw.charCodeAt(0) !== 0x22 || + raw.charCodeAt(raw.length - 1) !== 0x22 + ) { + throw new Error(`not a quoted string: ${raw}`); + } + return raw.slice(1, -1).replace(/\\(.)/g, "$1"); +}; + +const parseParamValue = (raw: string): ParamValue => { + const trimmed = raw.trim(); + if (trimmed.length === 0) return true; // boolean shorthand: `;key` means true + if (trimmed.startsWith('"')) return unquote(trimmed); + if (trimmed.startsWith(":") && trimmed.endsWith(":")) { + return decodeBase64(trimmed.slice(1, -1)); + } + if (trimmed === "?1") return true; + if (trimmed === "?0") return false; + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); + // Token (tchar+) — return as-is. RFC 8941 permits unquoted tokens; some + // implementations emit `alg=ed25519` without quotes. + return trimmed; +}; + +// Splits "(...);p1=v1;p2=v2" into ["(...)", "p1=v1", "p2=v2"] respecting +// quoted strings. The first element is always the value; the rest are params. +const splitEntry = (value: string): [string, string[]] => { + let inner: string; + let cursor: number; + const first = value.trimStart(); + if (first.startsWith("(")) { + const closeIdx = findUnquoted(value, ")", value.indexOf("(") + 1); + if (closeIdx === -1) throw new Error("unterminated inner list"); + inner = value.slice(value.indexOf("("), closeIdx + 1); + cursor = closeIdx + 1; + } else if (first.startsWith('"')) { + const openIdx = value.indexOf('"'); + const closeIdx = value.indexOf('"', openIdx + 1); + if (closeIdx === -1) throw new Error("unterminated quoted string"); + inner = value.slice(openIdx, closeIdx + 1); + cursor = closeIdx + 1; + } else { + // Bare token or byte-sequence — read until first `;`. + const semi = findUnquoted(value, ";", 0); + if (semi === -1) return [value.trim(), []]; + inner = value.slice(0, semi).trim(); + cursor = semi; + } + const params: string[] = []; + while (cursor < value.length) { + if (value[cursor] === ";") { + const next = findUnquoted(value, ";", cursor + 1); + const end = next === -1 ? value.length : next; + const p = value.slice(cursor + 1, end).trim(); + if (p.length > 0) params.push(p); + cursor = end; + } else { + cursor++; + } + } + return [inner, params]; +}; + +const parseParams = (entries: string[]): Record => { + const out: Record = {}; + for (const entry of entries) { + const eq = findUnquoted(entry, "=", 0); + if (eq === -1) { + out[entry.trim()] = true; + } else { + const key = entry.slice(0, eq).trim(); + const val = entry.slice(eq + 1).trim(); + out[key] = parseParamValue(val); + } + } + return out; +}; + +const parseCoveredComponents = (innerList: string): string[] => { + // innerList looks like `("@authority" "signature-agent")`. Split on + // whitespace between quoted items and strip the quotes. Ignores any + // per-item parameters (Web Bot Auth doesn't use them). + const inside = innerList.trim().replace(/^\(/, "").replace(/\)$/, ""); + const items: string[] = []; + let i = 0; + while (i < inside.length) { + while (i < inside.length && /\s/.test(inside[i] ?? "")) i++; + if (i >= inside.length) break; + if (inside[i] === '"') { + const end = inside.indexOf('"', i + 1); + if (end === -1) throw new Error("unterminated quoted item"); + items.push(unquote(inside.slice(i, end + 1))); + i = end + 1; + // Skip any per-item parameters up to next whitespace + while (i < inside.length && !/\s/.test(inside[i] ?? "")) i++; + } else { + // Unquoted token + let end = i; + while (end < inside.length && !/\s/.test(inside[end] ?? "")) end++; + items.push(inside.slice(i, end)); + i = end; + } + } + return items; +}; + +// Splits an RFC 8941 dictionary on top-level commas. Web Bot Auth requests +// carry a single entry today, but this keeps the parser correct for the +// multi-label case. +const splitDictEntries = (header: string): string[] => { + const entries: string[] = []; + let start = 0; + while (start < header.length) { + const comma = findUnquoted(header, ",", start); + const end = comma === -1 ? header.length : comma; + const raw = header.slice(start, end).trim(); + if (raw.length > 0) entries.push(raw); + start = end + 1; + } + return entries; +}; + +const parseFirstDictEntry = ( + header: string, +): { label: string; rawValue: string } | null => { + const entries = splitDictEntries(header); + const first = entries[0]; + if (!first) return null; + const eq = findUnquoted(first, "=", 0); + if (eq === -1) return null; + return { + label: first.slice(0, eq).trim(), + rawValue: first.slice(eq + 1).trim(), + }; +}; + +export const parseSignatureInput = ( + header: string, +): SignatureInputEntry | null => { + const first = parseFirstDictEntry(header); + if (!first) return null; + try { + const [innerList, params] = splitEntry(first.rawValue); + if (!innerList.startsWith("(")) return null; + return { + label: first.label, + coveredComponents: parseCoveredComponents(innerList), + params: parseParams(params), + serialisedValue: first.rawValue, + }; + } catch { + return null; + } +}; + +export const parseSignature = ( + header: string, + expectedLabel: string, +): Uint8Array | null => { + const first = parseFirstDictEntry(header); + if (!first || first.label !== expectedLabel) return null; + const raw = first.rawValue.trim(); + if (!raw.startsWith(":") || !raw.endsWith(":")) return null; + try { + return decodeBase64(raw.slice(1, -1)); + } catch { + return null; + } +}; diff --git a/packages/web-bot-auth/src/tests/signatureBase.test.ts b/packages/web-bot-auth/src/tests/signatureBase.test.ts new file mode 100644 index 0000000000..07332e6df8 --- /dev/null +++ b/packages/web-bot-auth/src/tests/signatureBase.test.ts @@ -0,0 +1,64 @@ +// 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. + +import { describe, expect, it } from "vitest"; +import { buildSignatureBase } from "../signatureBase.js"; + +describe("buildSignatureBase", () => { + it("builds the RFC 9421 base for @authority + signature-agent", () => { + const params = + '("@authority" "signature-agent");created=1735689600;expires=1735693200;keyid="abc";alg="ed25519";tag="web-bot-auth"'; + const base = buildSignatureBase( + ["@authority", "signature-agent"], + { + authority: "example.com", + signatureAgent: '"https://chatgpt.com"', + }, + params, + ); + expect(base).toBe( + [ + '"@authority": example.com', + '"signature-agent": "https://chatgpt.com"', + `"@signature-params": ${params}`, + ].join("\n"), + ); + }); + + it("preserves the covered-component order the signer chose", () => { + const params = '("signature-agent" "@authority");keyid="k"'; + const base = buildSignatureBase( + ["signature-agent", "@authority"], + { + authority: "example.com", + signatureAgent: 'g="https://agent.bot.goog"', + }, + params, + ); + const first = base.split("\n")[0]; + expect(first).toBe( + '"signature-agent": g="https://agent.bot.goog"', + ); + }); + + it("throws on an unsupported covered component", () => { + expect(() => + buildSignatureBase( + ["@method"], + { authority: "example.com", signatureAgent: '""' }, + "()", + ), + ).toThrow(/unsupported covered component/); + }); +}); diff --git a/packages/web-bot-auth/src/tests/structuredFields.test.ts b/packages/web-bot-auth/src/tests/structuredFields.test.ts new file mode 100644 index 0000000000..cb72ee540a --- /dev/null +++ b/packages/web-bot-auth/src/tests/structuredFields.test.ts @@ -0,0 +1,79 @@ +// 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. + +import { describe, expect, it } from "vitest"; +import { parseSignature, parseSignatureInput } from "../structuredFields.js"; + +describe("parseSignatureInput", () => { + it("parses a Web Bot Auth Signature-Input header", () => { + const header = + 'sig1=("@authority" "signature-agent");created=1735689600;expires=1735693200;keyid="abc";alg="ed25519";tag="web-bot-auth"'; + const entry = parseSignatureInput(header); + expect(entry).not.toBeNull(); + expect(entry?.label).toBe("sig1"); + expect(entry?.coveredComponents).toEqual([ + "@authority", + "signature-agent", + ]); + expect(entry?.params.created).toBe(1735689600); + expect(entry?.params.expires).toBe(1735693200); + expect(entry?.params.keyid).toBe("abc"); + expect(entry?.params.alg).toBe("ed25519"); + expect(entry?.params.tag).toBe("web-bot-auth"); + // serialisedValue keeps the "(...);..." substring verbatim so the + // signature-base @signature-params line reproduces it exactly. + expect(entry?.serialisedValue).toBe( + '("@authority" "signature-agent");created=1735689600;expires=1735693200;keyid="abc";alg="ed25519";tag="web-bot-auth"', + ); + }); + + it("returns null on a header with no `=`", () => { + expect(parseSignatureInput("garbage")).toBeNull(); + }); + + it("returns null when the value isn't an inner list", () => { + expect(parseSignatureInput('sig1="not-a-list"')).toBeNull(); + }); + + it("tolerates unquoted alg tokens (RFC 8941 tokens)", () => { + const header = 'sig1=("@authority");keyid="k";alg=ed25519'; + expect(parseSignatureInput(header)?.params.alg).toBe("ed25519"); + }); + + it("handles an empty inner list", () => { + expect(parseSignatureInput('sig1=();keyid="k"')?.coveredComponents).toEqual( + [], + ); + }); +}); + +describe("parseSignature", () => { + it("decodes the base64 byte-sequence for the matching label", () => { + // 3 bytes: 0x01 0x02 0x03 → base64 "AQID" + const bytes = parseSignature("sig1=:AQID:", "sig1"); + expect(bytes).toEqual(new Uint8Array([1, 2, 3])); + }); + + it("returns null when the label doesn't match", () => { + expect(parseSignature("sig1=:AQID:", "sig2")).toBeNull(); + }); + + it("returns null when the value isn't wrapped in colons", () => { + expect(parseSignature('sig1="AQID"', "sig1")).toBeNull(); + }); + + it("returns null on invalid base64", () => { + expect(parseSignature("sig1=:not_valid_base64!!!:", "sig1")).toBeNull(); + }); +}); diff --git a/packages/web-bot-auth/src/tests/verify.test.ts b/packages/web-bot-auth/src/tests/verify.test.ts new file mode 100644 index 0000000000..b5bd938058 --- /dev/null +++ b/packages/web-bot-auth/src/tests/verify.test.ts @@ -0,0 +1,191 @@ +// 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. + +// End-to-end round trip against `@noble/curves/ed25519`: generate a +// keypair, sign a signature base, construct the wire headers by hand, and +// hand the whole thing to `verifyWebBotAuth`. If any of the parser, base +// construction, or verification stages drift out of step this test breaks. + +import { ed25519 } from "@noble/curves/ed25519"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { clearJwksCache, type JwksFetch } from "../jwksResolver.js"; +import { buildSignatureBase } from "../signatureBase.js"; +import { verifyWebBotAuth } from "../verify.js"; + +// Encode raw bytes as base64 without depending on Node's Buffer type in +// the test file signature — atob/btoa work in Node 18+ too. +const toBase64 = (bytes: Uint8Array): string => { + let bin = ""; + for (let i = 0; i < bytes.length; i++) + bin += String.fromCharCode(bytes[i] as number); + return btoa(bin); +}; +const toBase64Url = (bytes: Uint8Array): string => + toBase64(bytes).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + +const KEY_ID = "test-key-1"; +const SIGNER_URL = "https://signer.example.com"; +const AUTHORITY = "target.example.com"; +const REQUEST_URL = `https://${AUTHORITY}/api/thing`; +const SIGNATURE_AGENT_HEADER = `"${SIGNER_URL}"`; + +const buildSignedRequest = ( + privateKey: Uint8Array, + publicKey: Uint8Array, + expiresSeconds: number, +): { + fetch: JwksFetch; + request: { + method: string; + url: string; + headers: Record; + }; +} => { + const created = Math.floor(Date.now() / 1000); + const params = `("@authority" "signature-agent");created=${created};expires=${expiresSeconds};keyid="${KEY_ID}";alg="ed25519";tag="web-bot-auth"`; + const base = buildSignatureBase( + ["@authority", "signature-agent"], + { authority: AUTHORITY, signatureAgent: SIGNATURE_AGENT_HEADER }, + params, + ); + const signature = ed25519.sign(new TextEncoder().encode(base), privateKey); + const jwks = { + keys: [ + { + kty: "OKP", + crv: "Ed25519", + alg: "EdDSA", + kid: KEY_ID, + x: toBase64Url(publicKey), + }, + ], + }; + const fetch: JwksFetch = async () => + new Response(JSON.stringify(jwks), { + status: 200, + headers: { "content-type": "application/json" }, + }); + return { + fetch, + request: { + method: "GET", + url: REQUEST_URL, + headers: { + "signature-agent": SIGNATURE_AGENT_HEADER, + "signature-input": `sig1=${params}`, + signature: `sig1=:${toBase64(signature)}:`, + }, + }, + }; +}; + +describe("verifyWebBotAuth", () => { + beforeEach(() => clearJwksCache()); + afterEach(() => clearJwksCache()); + + it("verifies a fresh, correctly-signed request", async () => { + const priv = ed25519.utils.randomPrivateKey(); + const pub = ed25519.getPublicKey(priv); + const { request, fetch } = buildSignedRequest( + priv, + pub, + Math.floor(Date.now() / 1000) + 60, + ); + + const result = await verifyWebBotAuth(request, { fetch }); + expect(result).toEqual({ + verified: true, + signerUrl: SIGNER_URL, + keyid: KEY_ID, + }); + }); + + it("rejects when signature bytes are flipped", async () => { + const priv = ed25519.utils.randomPrivateKey(); + const pub = ed25519.getPublicKey(priv); + const { request, fetch } = buildSignedRequest( + priv, + pub, + Math.floor(Date.now() / 1000) + 60, + ); + // Corrupt the signature body — decode, flip one byte, re-encode. + const orig = request.headers.signature ?? ""; + const b64 = orig.slice("sig1=:".length, -1); + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + bytes[0] = ((bytes[0] as number) ^ 0xff) & 0xff; + let corrupted = ""; + for (let i = 0; i < bytes.length; i++) + corrupted += String.fromCharCode(bytes[i] as number); + request.headers.signature = `sig1=:${btoa(corrupted)}:`; + + const result = await verifyWebBotAuth(request, { fetch }); + expect(result).toEqual({ verified: false, reason: "bad-signature" }); + }); + + it("rejects an expired signature before touching JWKS", async () => { + const priv = ed25519.utils.randomPrivateKey(); + const pub = ed25519.getPublicKey(priv); + const past = Math.floor(Date.now() / 1000) - 60; + const { request } = buildSignedRequest(priv, pub, past); + + let fetched = false; + const result = await verifyWebBotAuth(request, { + fetch: async () => { + fetched = true; + return new Response("{}", { status: 200 }); + }, + }); + expect(result).toEqual({ verified: false, reason: "expired" }); + expect(fetched).toBe(false); + }); + + it("rejects when the JWKS has no matching kid", async () => { + const priv = ed25519.utils.randomPrivateKey(); + const pub = ed25519.getPublicKey(priv); + const { request } = buildSignedRequest( + priv, + pub, + Math.floor(Date.now() / 1000) + 60, + ); + const jwks = { + keys: [ + { + kty: "OKP", + crv: "Ed25519", + kid: "some-other-kid", + x: toBase64Url(pub), + }, + ], + }; + const fetch: JwksFetch = async () => + new Response(JSON.stringify(jwks), { status: 200 }); + + const result = await verifyWebBotAuth(request, { fetch }); + expect(result).toEqual({ verified: false, reason: "no-matching-key" }); + }); + + it("returns no-signature-headers when the request is unsigned", async () => { + const result = await verifyWebBotAuth({ + method: "GET", + url: REQUEST_URL, + headers: {}, + }); + expect(result).toEqual({ + verified: false, + reason: "no-signature-headers", + }); + }); +}); diff --git a/packages/web-bot-auth/src/verify.ts b/packages/web-bot-auth/src/verify.ts new file mode 100644 index 0000000000..7bdc950a19 --- /dev/null +++ b/packages/web-bot-auth/src/verify.ts @@ -0,0 +1,180 @@ +// 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. + +import { ed25519 } from "@noble/curves/ed25519"; +import { decodeBase64Url } from "./base64.js"; +import { + type Jwk, + type JwksResolverOptions, + resolveJwksFromSignatureAgent, +} from "./jwksResolver.js"; +import { parseSignatureAgentHeader } from "./parseSignatureAgent.js"; +import { buildSignatureBase } from "./signatureBase.js"; +import { + parseSignature, + parseSignatureInput, +} from "./structuredFields.js"; + +// Loose request shape — anything with method, url and header lookup works. +// We don't couple to a specific HTTP framework's Request type. +export type VerifiableRequest = { + method: string; + url: string; + headers: + | Record + | { get(name: string): string | null }; +}; + +export type VerifyResult = + | { verified: true; signerUrl: string; keyid: string } + | { verified: false; reason: VerifyFailReason }; + +export type VerifyFailReason = + | "no-signature-headers" + | "unparseable-signature-agent" + | "unparseable-signature-input" + | "unparseable-signature" + | "unsupported-alg" + | "missing-keyid" + | "expired" + | "jwks-fetch-failed" + | "no-matching-key" + | "malformed-key" + | "bad-signature"; + +const readHeader = ( + headers: VerifiableRequest["headers"], + name: string, +): string | undefined => { + if ( + typeof (headers as { get?: unknown }).get === "function" + ) { + const map = headers as { get: (n: string) => string | null }; + return map.get(name) ?? map.get(name.toLowerCase()) ?? undefined; + } + const record = headers as Record; + const raw = record[name] ?? record[name.toLowerCase()]; + if (typeof raw === "string") return raw; + if (Array.isArray(raw) && typeof raw[0] === "string") return raw[0]; + return undefined; +}; + +// JWK → 32-byte Ed25519 public key. Rejects anything that isn't an OKP +// Ed25519 key so a signer publishing an RSA / ECDSA key in the same +// directory (which is legal for other signature purposes) never matches +// a web-bot-auth-tagged signature. +const jwkToEd25519PublicKey = (jwk: Jwk): Uint8Array => { + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") { + throw new Error("not an Ed25519 JWK"); + } + if (typeof jwk.x !== "string") { + throw new Error("Ed25519 JWK missing x parameter"); + } + const bytes = decodeBase64Url(jwk.x); + if (bytes.length !== 32) { + throw new Error(`Ed25519 public key must be 32 bytes, got ${bytes.length}`); + } + return bytes; +}; + +export const verifyWebBotAuth = async ( + request: VerifiableRequest, + options: JwksResolverOptions = {}, +): Promise => { + const signatureAgentRaw = readHeader(request.headers, "signature-agent"); + const signatureInputRaw = readHeader(request.headers, "signature-input"); + const signatureRaw = readHeader(request.headers, "signature"); + if (!signatureAgentRaw || !signatureInputRaw || !signatureRaw) { + return { verified: false, reason: "no-signature-headers" }; + } + + const signerUrl = parseSignatureAgentHeader(signatureAgentRaw); + if (!signerUrl) { + return { verified: false, reason: "unparseable-signature-agent" }; + } + + const inputEntry = parseSignatureInput(signatureInputRaw); + if (!inputEntry) { + return { verified: false, reason: "unparseable-signature-input" }; + } + + // Web Bot Auth pins alg=ed25519. Reject anything else rather than + // silently trying to verify against the wrong curve. + const alg = inputEntry.params.alg; + if (alg !== undefined && alg !== "ed25519") { + return { verified: false, reason: "unsupported-alg" }; + } + + // `expires` is seconds since epoch per RFC 9421. Treat any expired + // signature as invalid — this is the replay defence. + const expires = inputEntry.params.expires; + if (typeof expires === "number" && expires * 1000 < Date.now()) { + return { verified: false, reason: "expired" }; + } + + const keyid = inputEntry.params.keyid; + if (typeof keyid !== "string" || keyid.length === 0) { + return { verified: false, reason: "missing-keyid" }; + } + + const signatureBytes = parseSignature(signatureRaw, inputEntry.label); + if (!signatureBytes) { + return { verified: false, reason: "unparseable-signature" }; + } + + let jwks: Jwk[]; + try { + jwks = await resolveJwksFromSignatureAgent(signerUrl, options); + } catch { + return { verified: false, reason: "jwks-fetch-failed" }; + } + + const jwk = jwks.find((k) => k.kid === keyid); + if (!jwk) return { verified: false, reason: "no-matching-key" }; + + let publicKey: Uint8Array; + try { + publicKey = jwkToEd25519PublicKey(jwk); + } catch { + return { verified: false, reason: "malformed-key" }; + } + + let authority: string; + try { + authority = new URL(request.url).host.toLowerCase(); + } catch { + return { verified: false, reason: "bad-signature" }; + } + + let signatureBase: string; + try { + signatureBase = buildSignatureBase( + inputEntry.coveredComponents, + { authority, signatureAgent: signatureAgentRaw }, + inputEntry.serialisedValue, + ); + } catch { + return { verified: false, reason: "bad-signature" }; + } + + const message = new TextEncoder().encode(signatureBase); + try { + const ok = ed25519.verify(signatureBytes, message, publicKey); + return ok + ? { verified: true, signerUrl, keyid } + : { verified: false, reason: "bad-signature" }; + } catch { + return { verified: false, reason: "bad-signature" }; + } +}; diff --git a/packages/web-bot-auth/tsconfig.cjs.json b/packages/web-bot-auth/tsconfig.cjs.json new file mode 100644 index 0000000000..0604462c84 --- /dev/null +++ b/packages/web-bot-auth/tsconfig.cjs.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.cjs.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist/cjs" + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.json", + "./src/**/*.d.ts", + "./src/**/*.tsx" + ], + "references": [ + { + "path": "../../dev/config/tsconfig.cjs.json" + } + ] +} diff --git a/packages/web-bot-auth/tsconfig.json b/packages/web-bot-auth/tsconfig.json new file mode 100644 index 0000000000..f5e764b56e --- /dev/null +++ b/packages/web-bot-auth/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.esm.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": [ + "src", + "src/**/*.json", + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.d.ts" + ], + "references": [ + { + "path": "../../dev/config/tsconfig.json" + } + ] +} diff --git a/packages/web-bot-auth/tsconfig.types.json b/packages/web-bot-auth/tsconfig.types.json new file mode 100644 index 0000000000..e2d2929993 --- /dev/null +++ b/packages/web-bot-auth/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "composite": false + } +} diff --git a/packages/web-bot-auth/vite.cjs.config.ts b/packages/web-bot-auth/vite.cjs.config.ts new file mode 100644 index 0000000000..42b8246e61 --- /dev/null +++ b/packages/web-bot-auth/vite.cjs.config.ts @@ -0,0 +1,23 @@ +// 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. + +import path from "node:path"; +import { ViteCommonJSConfig } from "@prosopo/config"; + +export default function () { + return ViteCommonJSConfig( + path.basename("."), + path.resolve("./tsconfig.json"), + ); +} diff --git a/packages/web-bot-auth/vite.esm.config.ts b/packages/web-bot-auth/vite.esm.config.ts new file mode 100644 index 0000000000..61eb56d7f1 --- /dev/null +++ b/packages/web-bot-auth/vite.esm.config.ts @@ -0,0 +1,20 @@ +// 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. + +import path from "node:path"; +import { ViteEsmConfig } from "@prosopo/config"; + +export default function () { + return ViteEsmConfig(path.basename("."), path.resolve("./tsconfig.json")); +} diff --git a/packages/web-bot-auth/vite.test.config.ts b/packages/web-bot-auth/vite.test.config.ts new file mode 100644 index 0000000000..fc6e130f21 --- /dev/null +++ b/packages/web-bot-auth/vite.test.config.ts @@ -0,0 +1,19 @@ +// 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. + +import { ViteTestConfig } from "@prosopo/config"; + +process.env.NODE_ENV = "test"; + +export default ViteTestConfig(); From 498a6b59670ca9408ae30dced6d59011e050bfa5 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 26 Aug 2026 20:50:35 +0100 Subject: [PATCH 2/4] fix(dev/config): remove obsolete @ts-expect-error on polyfillRequire Upstream Vite's Rollup output types now include polyfillRequire, so the directive is unused and TSC flags it as such. Drop the suppression. --- dev/config/src/vite/vite.esm.config.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/dev/config/src/vite/vite.esm.config.ts b/dev/config/src/vite/vite.esm.config.ts index e69223e420..85a67006c5 100644 --- a/dev/config/src/vite/vite.esm.config.ts +++ b/dev/config/src/vite/vite.esm.config.ts @@ -84,10 +84,7 @@ export default async function ( // package (e.g. catcher-demo importing @prosopo/util) dies with // 'Module "node:module" has been externalized for browser // compatibility' before React can mount. Rollup emitted no such - // runtime, so this only bites under Vite 8. The option is a - // Rolldown extension; Vite 8's re-exported Rollup types don't - // carry it yet. - // @ts-expect-error — Rolldown option, not in Rollup's OutputOptions + // runtime, so this only bites under Vite 8. polyfillRequire: false, }, }, From b41af39f2f18c30f473998f9f6864dd36ac50a68 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 27 Aug 2026 14:38:13 +0100 Subject: [PATCH 3/4] feat(provider,api,server): support clientSessionId + email on authenticated captcha type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the Web Bot Auth authenticated fast-path so it participates in the render-time correlation the pow/image/puzzle flows enforce. - `GetFrictionlessCaptchaChallengeRequestBody` gains optional `clientSessionId` — captured at issuance and persisted onto the session record's `clientMetaData`, mirroring how the other captcha types store it. - `verifyAuthenticatedSession` compares via the shared `isClientSessionMismatch` helper — same asymmetric semantics as pow/image/puzzle: verify-side opts in; a real mismatch fails with `API.CLIENT_SESSION_MISMATCH`. - `ProviderApi.submitAuthenticatedCaptchaVerify(token, sig, user, ip?, email?, clientSessionId?)` — dedicated endpoint call so `@prosopo/server` can dispatch on captchaType without a token-shape branch. Email is a wire-parity passthrough (no email correlation on authenticated — Web Bot Auth doesn't carry one). - `verifyProvider` gets a `CaptchaType.authenticated` branch that routes to the new method with ip + email + clientSessionId forwarded. Net effect: a customer's siteverify Lambda passes an authenticated token to `@prosopo/server.isVerified` and it dispatches correctly instead of falling through to the legacy image path. Co-Authored-By: Claude Opus 4.7 --- packages/api/src/api/ProviderApi.ts | 42 +++++++++++++++++++ .../handler.ts | 2 + packages/provider/src/api/verify.ts | 3 +- .../tasks/frictionless/frictionlessTasks.ts | 26 ++++++++++++ packages/server/src/server.ts | 30 +++++++++++-- packages/types/src/provider/api.ts | 9 ++++ 6 files changed, 108 insertions(+), 4 deletions(-) diff --git a/packages/api/src/api/ProviderApi.ts b/packages/api/src/api/ProviderApi.ts index aef1cc838c..300b46bbba 100644 --- a/packages/api/src/api/ProviderApi.ts +++ b/packages/api/src/api/ProviderApi.ts @@ -518,6 +518,48 @@ export default class ProviderApi }); } + /** + * Verify an authenticated (Web Bot Auth fast-path) session token. Wire + * shape matches the pow/image/puzzle verify calls — same `ip` binding, same + * optional `clientSessionId` correlation, so `@prosopo/server` can dispatch + * without a token-shape branch. Provider-side the router routes to + * `verifyAuthenticatedSession`, which additionally requires `ip` (a token + * minted for a signed request is meaningless without the IP it was issued + * to) and flips `serverChecked=true` on first use for single-consumption. + */ + public submitAuthenticatedCaptchaVerify( + token: string, + signatureHex: string, + user: string, + ip?: string, + email?: string, + clientSessionId?: string, + ): Promise { + const body: ServerPowCaptchaVerifyRequestBodyType = { + [ApiParams.token]: token, + [ApiParams.dappSignature]: signatureHex, + [ApiParams.ip]: ip, + }; + // Email is a wire-parity passthrough: the authenticated flow has no + // email captured at issuance (Web Bot Auth doesn't carry one), so no + // spam-filter / correlation runs on it server-side. Forwarded anyway so + // operator logs and verdict rows on the provider stay symmetric with + // pow/image/puzzle and a customer's siteverify call doesn't have to + // know which captchaType the token was minted with. + if (email) { + body[ApiParams.email] = email; + } + if (clientSessionId) { + body[ApiParams.clientSessionId] = clientSessionId; + } + return this.post(ClientApiPaths.VerifyAuthenticatedSession, body, { + headers: { + "Prosopo-Site-Key": this.account, + "Prosopo-User": user, + }, + }); + } + public registerSiteKey( siteKey: string, tier: Tier, diff --git a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts index 4e9be87946..4589ba2967 100644 --- a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts +++ b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts @@ -93,6 +93,7 @@ export default ( detectorSessionId, currentUrl: reportedCurrentUrl, iframeUrl: reportedIframeUrl, + clientSessionId, } = GetFrictionlessCaptchaChallengeRequestBody.parse(req.body); // Re-sanitise whatever the client reported: keep only scheme + host @@ -638,6 +639,7 @@ export default ( req.ipInfo && "isValid" in req.ipInfo && req.ipInfo.isValid ? req.ipInfo : undefined, + clientSessionId, ); req.logger.info(() => ({ msg: "Frictionless decision", diff --git a/packages/provider/src/api/verify.ts b/packages/provider/src/api/verify.ts index 0b0c7d83d6..0f5db1d38a 100644 --- a/packages/provider/src/api/verify.ts +++ b/packages/provider/src/api/verify.ts @@ -615,7 +615,7 @@ export function prosopoVerifyRouter(env: ProviderEnvironment): Router { ); } - const { dappSignature, token, ip } = parsed; + const { dappSignature, token, ip, clientSessionId } = parsed; try { const { user, @@ -675,6 +675,7 @@ export function prosopoVerifyRouter(env: ProviderEnvironment): Router { await tasks.frictionlessManager.verifyAuthenticatedSession( sessionId, ip, + clientSessionId, ); res.json(outcome); } catch (err) { diff --git a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts index 6c39b470f2..a97582bdbe 100644 --- a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts +++ b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts @@ -42,6 +42,7 @@ import { buildAllWindowIncrements, } from "../../util/usageCounters.js"; import { CaptchaManager } from "../captchaManager.js"; +import { isClientSessionMismatch } from "../../utils/clientMetaData.js"; import { ipMatchesSession } from "./ipMatch.js"; import { DecisionMachineRunner } from "../decisionMachine/decisionMachineRunner.js"; import { getBotScore } from "../detection/getBotScore.js"; @@ -359,6 +360,7 @@ export class FrictionlessManager extends CaptchaManager { userSitekeyIpHash?: string, headers?: RequestHeaders, ipInfo?: IPInfoResponse, + clientSessionId?: string, ): Promise { const sessionRecord: Session = { sessionId: `${getSessionIDPrefix(this.config.host)}-${uuidv4()}`, @@ -381,6 +383,12 @@ export class FrictionlessManager extends CaptchaManager { webBotAuthAgent, ...(ipInfo && { ipInfo }), ...(headers && { headers }), + // Same shape as pow/image/puzzle: the render-time session id lives + // on `clientMetaData.clientSessionId` so the verify-side comparison + // is a straight equality check on the identical field regardless of + // captcha type. Absent when the client didn't supply one, in which + // case the verify check is a no-op (matches pow/image/puzzle). + ...(clientSessionId && { clientMetaData: { clientSessionId } }), }; await this.db.storeSessionRecord(sessionRecord); @@ -421,6 +429,7 @@ export class FrictionlessManager extends CaptchaManager { async verifyAuthenticatedSession( sessionId: string, ip: string | undefined, + clientSessionId: string | undefined, ): Promise<{ verified: boolean; status: string }> { if (!ip) { return { @@ -453,6 +462,23 @@ export class FrictionlessManager extends CaptchaManager { status: "API.AUTHENTICATED_IP_MISMATCH", }; } + // Same semantics as pow/image/puzzle: `expected` is the id the dapp + // server just supplied on the verify call, `recorded` is the id the + // session was minted with. A site that doesn't opt in to correlation + // (no `expected`) is a no-op; anything else — including "expected set + // but nothing recorded" — is a mismatch. Uses the shared helper so the + // authenticated flow can't drift from the other captcha types. + if ( + isClientSessionMismatch( + clientSessionId, + session.clientMetaData?.clientSessionId, + ) + ) { + return { + verified: false, + status: "API.CLIENT_SESSION_MISMATCH", + }; + } await this.db.updateSessionRecord(sessionId, { serverChecked: true }); return { verified: true, status: "API.USER_VERIFIED" }; } diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index a4ea42d397..83434c3493 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -59,9 +59,10 @@ export class ProsopoServer { /** * Verify a token with the issuing provider. Dispatches to the correct * verify endpoint by inspecting the token's declared captchaType: - * - puzzle → submitPuzzleCaptchaVerify - * - pow → submitPowCaptchaVerify - * - image → verifyDappUser + * - puzzle → submitPuzzleCaptchaVerify + * - pow → submitPowCaptchaVerify + * - image → verifyDappUser + * - authenticated → submitAuthenticatedCaptchaVerify (Web Bot Auth fast-path) * When captchaType is absent (a legacy token minted before the field was * added), fall back to the historical heuristic: presence of `challenge` * routes to PoW, absence routes to image. Legacy puzzle tokens follow the @@ -134,6 +135,29 @@ export class ProsopoServer { ); } + if (captchaType === CaptchaType.authenticated) { + // Web Bot Auth fast-path — the token was minted for a request that + // already cleared cryptographic signer verification, so there is no + // captcha to solve and no bot score to reason about. Freshness + // piggybacks on the PoW timeout (same order of magnitude, no need + // for a separate config value). `ip` is mandatory server-side and + // enforced there; passed through here as-is so the provider can + // return API.AUTHENTICATED_IP_REQUIRED with a real error label + // rather than a generic "verify failed". + const authenticatedTimeout = this.config.timeouts.pow.cachedTimeout; + if (!this.isRecent(timestamp, authenticatedTimeout, "Authenticated")) { + return this.notRecentResponse(); + } + return await providerApi.submitAuthenticatedCaptchaVerify( + token, + signatureHex, + user, + ip, + email, + clientSessionId, + ); + } + if (captchaType === CaptchaType.image) { const imageTimeout = this.config.timeouts.image.cachedTimeout; if (!this.isRecent(timestamp, imageTimeout, "Image")) { diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index e72f409014..c0ea1ad72a 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -620,6 +620,15 @@ export const GetFrictionlessCaptchaChallengeRequestBody = object({ // server-side; not gated in the decision machine. [ApiParams.currentUrl]: boundedString(INPUT_LIMITS.URL).optional(), [ApiParams.iframeUrl]: boundedString(INPUT_LIMITS.URL).optional(), + // Same wire semantics as VerifySolutionBody.clientSessionId — a per-render + // session id the client (Bumblebee's JTI, a customer widget's `sessionId`, + // anything else the site owner supplies) uses to bind a captcha token to + // the render it was earned in. On the authenticated fast-path the value is + // persisted onto the session's clientMetaData; /authenticated/verify + // rejects with API.CLIENT_SESSION_MISMATCH when the forwarded value + // doesn't match, so a token exfiltrated to a different render is dead on + // arrival even if it clears the IP-binding check. + [ApiParams.clientSessionId]: boundedString(INPUT_LIMITS.ID).optional(), }); export type GetFrictionlessCaptchaChallengeRequestBodyOutput = output< From 9c3a50d7f566d7501740d63898cbbaf52728d625 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 27 Aug 2026 16:36:31 +0100 Subject: [PATCH 4/4] lint-fix --- .gitignore | 1 + .../procaptcha-frictionless/src/AuthenticatedBadge.tsx | 1 + .../captcha/getFrictionlessCaptchaChallenge/handler.ts | 2 +- .../provider/src/tasks/frictionless/frictionlessTasks.ts | 4 ++-- packages/web-bot-auth/src/jwksResolver.ts | 4 +--- packages/web-bot-auth/src/tests/signatureBase.test.ts | 4 +--- packages/web-bot-auth/src/tests/structuredFields.test.ts | 5 +---- packages/web-bot-auth/src/tests/verify.test.ts | 2 +- packages/web-bot-auth/src/verify.ts | 9 ++------- 9 files changed, 11 insertions(+), 21 deletions(-) diff --git a/.gitignore b/.gitignore index ca39138b61..833bdfe7c7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.nx .yarn/* !.yarn/patches !.yarn/plugins diff --git a/packages/procaptcha-frictionless/src/AuthenticatedBadge.tsx b/packages/procaptcha-frictionless/src/AuthenticatedBadge.tsx index 93dcfe1a44..bbe6d327f1 100644 --- a/packages/procaptcha-frictionless/src/AuthenticatedBadge.tsx +++ b/packages/procaptcha-frictionless/src/AuthenticatedBadge.tsx @@ -105,6 +105,7 @@ export const AuthenticatedBadge: FC = ({ fontSize: 13, lineHeight: 1.4, }} + // biome-ignore lint/a11y/useSemanticElements: doesn't make sense role="status" aria-label="Verified agent" > diff --git a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts index 4589ba2967..54fb3a7a3e 100644 --- a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts +++ b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge/handler.ts @@ -25,8 +25,8 @@ import { AccessPolicyType, type AccessRulesStorage, } from "@prosopo/user-access-policy"; -import { verifyWebBotAuth } from "@prosopo/web-bot-auth"; import { flatten, isProtectDeployment, sanitisePageUrl } from "@prosopo/util"; +import { verifyWebBotAuth } from "@prosopo/web-bot-auth"; import type { NextFunction, Request, Response } from "express"; import { v4 as uuidv4 } from "uuid"; import { getCompositeIpAddress } from "../../../compositeIpAddress.js"; diff --git a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts index a97582bdbe..ed4c1c42d8 100644 --- a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts +++ b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts @@ -41,12 +41,12 @@ import { type UsageCounters, buildAllWindowIncrements, } from "../../util/usageCounters.js"; -import { CaptchaManager } from "../captchaManager.js"; import { isClientSessionMismatch } from "../../utils/clientMetaData.js"; -import { ipMatchesSession } from "./ipMatch.js"; +import { CaptchaManager } from "../captchaManager.js"; import { DecisionMachineRunner } from "../decisionMachine/decisionMachineRunner.js"; import { getBotScore } from "../detection/getBotScore.js"; import { downgradePuzzleIfUnavailable } from "../puzzle/puzzleRenderer.js"; +import { ipMatchesSession } from "./ipMatch.js"; import { type RoutingContext, applyRouter } from "./routingMachine.js"; const DEFAULT_MAX_TIMESTAMP_AGE = 60 * 10 * 1000; // 10 minutes diff --git a/packages/web-bot-auth/src/jwksResolver.ts b/packages/web-bot-auth/src/jwksResolver.ts index 814be67e22..29c9f95a38 100644 --- a/packages/web-bot-auth/src/jwksResolver.ts +++ b/packages/web-bot-auth/src/jwksResolver.ts @@ -64,9 +64,7 @@ export const resolveJwksFromSignatureAgent = async ( const directoryUrl = new URL(DIRECTORY_PATH, `${signerUrl}/`).toString(); const response = await fetchImpl(directoryUrl); if (!response.ok) { - throw new Error( - `JWKS fetch ${response.status} at ${directoryUrl}`, - ); + throw new Error(`JWKS fetch ${response.status} at ${directoryUrl}`); } const body = (await response.json()) as { keys?: Jwk[] }; diff --git a/packages/web-bot-auth/src/tests/signatureBase.test.ts b/packages/web-bot-auth/src/tests/signatureBase.test.ts index 07332e6df8..0321f66561 100644 --- a/packages/web-bot-auth/src/tests/signatureBase.test.ts +++ b/packages/web-bot-auth/src/tests/signatureBase.test.ts @@ -47,9 +47,7 @@ describe("buildSignatureBase", () => { params, ); const first = base.split("\n")[0]; - expect(first).toBe( - '"signature-agent": g="https://agent.bot.goog"', - ); + expect(first).toBe('"signature-agent": g="https://agent.bot.goog"'); }); it("throws on an unsupported covered component", () => { diff --git a/packages/web-bot-auth/src/tests/structuredFields.test.ts b/packages/web-bot-auth/src/tests/structuredFields.test.ts index cb72ee540a..1de024abe5 100644 --- a/packages/web-bot-auth/src/tests/structuredFields.test.ts +++ b/packages/web-bot-auth/src/tests/structuredFields.test.ts @@ -22,10 +22,7 @@ describe("parseSignatureInput", () => { const entry = parseSignatureInput(header); expect(entry).not.toBeNull(); expect(entry?.label).toBe("sig1"); - expect(entry?.coveredComponents).toEqual([ - "@authority", - "signature-agent", - ]); + expect(entry?.coveredComponents).toEqual(["@authority", "signature-agent"]); expect(entry?.params.created).toBe(1735689600); expect(entry?.params.expires).toBe(1735693200); expect(entry?.params.keyid).toBe("abc"); diff --git a/packages/web-bot-auth/src/tests/verify.test.ts b/packages/web-bot-auth/src/tests/verify.test.ts index b5bd938058..74ade3cd03 100644 --- a/packages/web-bot-auth/src/tests/verify.test.ts +++ b/packages/web-bot-auth/src/tests/verify.test.ts @@ -19,7 +19,7 @@ import { ed25519 } from "@noble/curves/ed25519"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { clearJwksCache, type JwksFetch } from "../jwksResolver.js"; +import { type JwksFetch, clearJwksCache } from "../jwksResolver.js"; import { buildSignatureBase } from "../signatureBase.js"; import { verifyWebBotAuth } from "../verify.js"; diff --git a/packages/web-bot-auth/src/verify.ts b/packages/web-bot-auth/src/verify.ts index 7bdc950a19..f5dbaceb70 100644 --- a/packages/web-bot-auth/src/verify.ts +++ b/packages/web-bot-auth/src/verify.ts @@ -21,10 +21,7 @@ import { } from "./jwksResolver.js"; import { parseSignatureAgentHeader } from "./parseSignatureAgent.js"; import { buildSignatureBase } from "./signatureBase.js"; -import { - parseSignature, - parseSignatureInput, -} from "./structuredFields.js"; +import { parseSignature, parseSignatureInput } from "./structuredFields.js"; // Loose request shape — anything with method, url and header lookup works. // We don't couple to a specific HTTP framework's Request type. @@ -57,9 +54,7 @@ const readHeader = ( headers: VerifiableRequest["headers"], name: string, ): string | undefined => { - if ( - typeof (headers as { get?: unknown }).get === "function" - ) { + if (typeof (headers as { get?: unknown }).get === "function") { const map = headers as { get: (n: string) => string | null }; return map.get(name) ?? map.get(name.toLowerCase()) ?? undefined; }