@buildersgarden/siwa 0.0.20 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * captcha.ts
3
+ *
4
+ * Reverse CAPTCHA — inspired by MoltCaptcha (https://github.com/MoltCaptcha/MoltCaptcha).
5
+ *
6
+ * Challenges exploit how LLMs generate text in a single autoregressive pass
7
+ * (satisfying multiple constraints simultaneously), while humans must iterate.
8
+ *
9
+ * Two integration points:
10
+ * 1. Sign-in flow — server can require captcha before issuing a nonce
11
+ * 2. Per-request — middleware randomly challenges agents during authenticated API calls
12
+ *
13
+ * No external dependencies — only `node:crypto`.
14
+ */
15
+ export type CaptchaDifficulty = 'easy' | 'medium' | 'hard' | 'extreme';
16
+ export type CaptchaFormat = 'haiku' | 'quatrain' | 'free_verse' | 'micro_story';
17
+ export interface CaptchaChallenge {
18
+ topic: string;
19
+ format: CaptchaFormat;
20
+ lineCount: number;
21
+ asciiTarget: number;
22
+ wordCount?: number;
23
+ charPosition?: [number, string];
24
+ totalChars?: number;
25
+ timeLimitSeconds: number;
26
+ difficulty: CaptchaDifficulty;
27
+ createdAt: number;
28
+ }
29
+ export interface CaptchaSolution {
30
+ text: string;
31
+ solvedAt: number;
32
+ }
33
+ export interface CaptchaVerificationResult {
34
+ asciiSum: {
35
+ pass: boolean;
36
+ actual: number;
37
+ target: number;
38
+ };
39
+ lineCount?: {
40
+ pass: boolean;
41
+ actual: number;
42
+ target: number;
43
+ };
44
+ wordCount?: {
45
+ pass: boolean;
46
+ actual: number;
47
+ target: number;
48
+ };
49
+ charPosition?: {
50
+ pass: boolean;
51
+ };
52
+ totalChars?: {
53
+ pass: boolean;
54
+ actual: number;
55
+ target?: number;
56
+ };
57
+ timing: {
58
+ pass: boolean;
59
+ elapsedSeconds: number;
60
+ };
61
+ overallPass: boolean;
62
+ verdict: 'VERIFIED_AI_AGENT' | 'CHALLENGE_FAILED';
63
+ }
64
+ export type CaptchaPolicy = (context: {
65
+ address: string;
66
+ agentId: number;
67
+ agentRegistry: string;
68
+ request?: Request;
69
+ }) => CaptchaDifficulty | null | Promise<CaptchaDifficulty | null>;
70
+ export interface CaptchaVerifyOptions {
71
+ /** Use server wall-clock time instead of trusting the agent's solvedAt. Default: true */
72
+ useServerTiming?: boolean;
73
+ /** Extra seconds added to time limit for network latency. Default: 2 */
74
+ timingToleranceSeconds?: number;
75
+ /** Whether verification results include actual vs target values. Default: true */
76
+ revealConstraints?: boolean;
77
+ /** Tolerance for ASCII sum comparison (±N). Default: 0 (exact match) */
78
+ asciiTolerance?: number;
79
+ /** Callback to consume a challenge token (one-time use). Return false to reject replays. */
80
+ consumeChallenge?: (challengeToken: string) => boolean | Promise<boolean>;
81
+ }
82
+ export interface CaptchaOptions {
83
+ secret: string;
84
+ topics?: string[];
85
+ formats?: CaptchaFormat[];
86
+ /** Override difficulty settings per tier */
87
+ difficulties?: Partial<Record<CaptchaDifficulty, Partial<DifficultyConfig>>>;
88
+ /** Verification options (passed through to verifyCaptchaSolution) */
89
+ verify?: CaptchaVerifyOptions;
90
+ }
91
+ export declare const CHALLENGE_HEADER = "X-SIWA-Challenge";
92
+ export declare const CHALLENGE_RESPONSE_HEADER = "X-SIWA-Challenge-Response";
93
+ export interface DifficultyConfig {
94
+ timeLimitSeconds: number;
95
+ lineCount: [number, number];
96
+ useWordCount: boolean;
97
+ useCharPosition: boolean;
98
+ useTotalChars: boolean;
99
+ }
100
+ /**
101
+ * Generate a captcha challenge and HMAC-signed token.
102
+ *
103
+ * @param difficulty Challenge difficulty tier
104
+ * @param options Secret for HMAC signing + optional topic/format pools
105
+ * @returns `{ challenge, challengeToken }` — the challenge data and its signed token
106
+ */
107
+ export declare function createCaptchaChallenge(difficulty: CaptchaDifficulty, options: CaptchaOptions): {
108
+ challenge: CaptchaChallenge;
109
+ challengeToken: string;
110
+ };
111
+ /**
112
+ * Verify a captcha solution against a signed challenge token.
113
+ *
114
+ * @param challengeToken HMAC-signed token from createCaptchaChallenge
115
+ * @param solution The agent's solution (text + timestamp)
116
+ * @param secret HMAC secret used to sign the token
117
+ * @param options Verification options (timing, tolerance, replay protection, redaction)
118
+ * @returns Verification result, or `null` if the token is invalid/replayed
119
+ */
120
+ export declare function verifyCaptchaSolution(challengeToken: string, solution: CaptchaSolution, secret: string, options?: CaptchaVerifyOptions): Promise<CaptchaVerificationResult | null>;
121
+ /**
122
+ * Callback that receives a captcha challenge and returns the solution text.
123
+ *
124
+ * Typically implemented by prompting an LLM to generate text that satisfies
125
+ * all constraints (line count, ASCII sum, word count, etc.) in a single pass.
126
+ */
127
+ export type CaptchaSolver = (challenge: CaptchaChallenge) => string | Promise<string>;
128
+ /**
129
+ * Detect and solve a captcha challenge from a nonce response.
130
+ *
131
+ * When a server's nonce endpoint returns `{ status: 'captcha_required' }`,
132
+ * this helper calls the solver, packs the response, and returns a
133
+ * `challengeResponse` string for the agent to include in the retry request.
134
+ *
135
+ * @param nonceResponse The parsed JSON body from the nonce endpoint
136
+ * @param solver Callback that generates solution text from a challenge
137
+ * @returns `{ solved: true, challengeResponse }` if captcha was solved,
138
+ * `{ solved: false }` if no captcha was required
139
+ *
140
+ * @example
141
+ * ```typescript
142
+ * import { solveCaptchaChallenge } from '@buildersgarden/siwa/captcha';
143
+ *
144
+ * const res = await fetch('/siwa/nonce', { method: 'POST', body: JSON.stringify(params) });
145
+ * const nonceResult = await res.json();
146
+ *
147
+ * const captcha = await solveCaptchaChallenge(nonceResult, async (challenge) => {
148
+ * // LLM generates text satisfying all constraints
149
+ * return await generateText(challenge); // your LLM solver
150
+ * });
151
+ *
152
+ * if (captcha.solved) {
153
+ * // Retry with challenge response
154
+ * const retry = await fetch('/siwa/nonce', {
155
+ * method: 'POST',
156
+ * body: JSON.stringify({ ...params, challengeResponse: captcha.challengeResponse }),
157
+ * });
158
+ * }
159
+ * ```
160
+ */
161
+ export declare function solveCaptchaChallenge(nonceResponse: {
162
+ status: string;
163
+ challenge?: CaptchaChallenge;
164
+ challengeToken?: string;
165
+ }, solver: CaptchaSolver): Promise<{
166
+ solved: true;
167
+ challengeResponse: string;
168
+ } | {
169
+ solved: false;
170
+ }>;
171
+ /**
172
+ * Package a captcha solution for submission (agent-side).
173
+ *
174
+ * @param challengeToken The challenge token received from the server
175
+ * @param text The agent's solution text
176
+ * @returns A packed string to send in the challenge response header/field
177
+ */
178
+ export declare function packCaptchaResponse(challengeToken: string, text: string): string;
179
+ /**
180
+ * Unpack a captcha response (server-side).
181
+ *
182
+ * @param packed The base64url-encoded response from the agent
183
+ * @returns The challenge token and solution, or `null` if malformed
184
+ */
185
+ export declare function unpackCaptchaResponse(packed: string): {
186
+ challengeToken: string;
187
+ solution: CaptchaSolution;
188
+ } | null;
@@ -0,0 +1,335 @@
1
+ /**
2
+ * captcha.ts
3
+ *
4
+ * Reverse CAPTCHA — inspired by MoltCaptcha (https://github.com/MoltCaptcha/MoltCaptcha).
5
+ *
6
+ * Challenges exploit how LLMs generate text in a single autoregressive pass
7
+ * (satisfying multiple constraints simultaneously), while humans must iterate.
8
+ *
9
+ * Two integration points:
10
+ * 1. Sign-in flow — server can require captcha before issuing a nonce
11
+ * 2. Per-request — middleware randomly challenges agents during authenticated API calls
12
+ *
13
+ * No external dependencies — only `node:crypto`.
14
+ */
15
+ import * as crypto from 'crypto';
16
+ // ---------------------------------------------------------------------------
17
+ // Constants
18
+ // ---------------------------------------------------------------------------
19
+ export const CHALLENGE_HEADER = 'X-SIWA-Challenge';
20
+ export const CHALLENGE_RESPONSE_HEADER = 'X-SIWA-Challenge-Response';
21
+ const DEFAULT_TOPICS = [
22
+ 'quantum computing',
23
+ 'neural networks',
24
+ 'blockchain consensus',
25
+ 'compiler optimization',
26
+ 'distributed systems',
27
+ 'cryptographic hashing',
28
+ 'orbital mechanics',
29
+ 'gene editing',
30
+ 'photonic circuits',
31
+ 'autonomous navigation',
32
+ 'protein folding',
33
+ 'dark matter',
34
+ 'fusion reactors',
35
+ 'swarm robotics',
36
+ 'topological insulators',
37
+ 'zero-knowledge proofs',
38
+ ];
39
+ const DEFAULT_FORMATS = ['haiku', 'quatrain', 'free_verse', 'micro_story'];
40
+ const DIFFICULTY_TABLE = {
41
+ easy: {
42
+ timeLimitSeconds: 30,
43
+ lineCount: [3, 4],
44
+ useWordCount: false,
45
+ useCharPosition: false,
46
+ useTotalChars: false,
47
+ },
48
+ medium: {
49
+ timeLimitSeconds: 20,
50
+ lineCount: [4, 6],
51
+ useWordCount: true,
52
+ useCharPosition: false,
53
+ useTotalChars: false,
54
+ },
55
+ hard: {
56
+ timeLimitSeconds: 15,
57
+ lineCount: [4, 6],
58
+ useWordCount: true,
59
+ useCharPosition: true,
60
+ useTotalChars: false,
61
+ },
62
+ extreme: {
63
+ timeLimitSeconds: 10,
64
+ lineCount: [5, 7],
65
+ useWordCount: true,
66
+ useCharPosition: true,
67
+ useTotalChars: true,
68
+ },
69
+ };
70
+ const FORMAT_LINE_COUNTS = {
71
+ haiku: 3,
72
+ quatrain: 4,
73
+ free_verse: 5,
74
+ micro_story: 4,
75
+ };
76
+ // ---------------------------------------------------------------------------
77
+ // HMAC token helpers (same pattern as receipt.ts)
78
+ // ---------------------------------------------------------------------------
79
+ function signToken(payload, secret) {
80
+ const data = Buffer.from(JSON.stringify(payload)).toString('base64url');
81
+ const sig = crypto.createHmac('sha256', secret).update(data).digest('base64url');
82
+ return `${data}.${sig}`;
83
+ }
84
+ function verifyToken(token, secret) {
85
+ const dotIdx = token.indexOf('.');
86
+ if (dotIdx === -1)
87
+ return null;
88
+ const data = token.slice(0, dotIdx);
89
+ const sig = token.slice(dotIdx + 1);
90
+ if (!data || !sig)
91
+ return null;
92
+ const expected = crypto.createHmac('sha256', secret).update(data).digest('base64url');
93
+ if (sig.length !== expected.length)
94
+ return null;
95
+ if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)))
96
+ return null;
97
+ try {
98
+ return JSON.parse(Buffer.from(data, 'base64url').toString());
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // Helpers
106
+ // ---------------------------------------------------------------------------
107
+ function randomInt(min, max) {
108
+ return min + (crypto.randomInt(max - min + 1));
109
+ }
110
+ function randomChoice(arr) {
111
+ return arr[crypto.randomInt(arr.length)];
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Server-side: challenge generation
115
+ // ---------------------------------------------------------------------------
116
+ /**
117
+ * Generate a captcha challenge and HMAC-signed token.
118
+ *
119
+ * @param difficulty Challenge difficulty tier
120
+ * @param options Secret for HMAC signing + optional topic/format pools
121
+ * @returns `{ challenge, challengeToken }` — the challenge data and its signed token
122
+ */
123
+ export function createCaptchaChallenge(difficulty, options) {
124
+ const baseConfig = DIFFICULTY_TABLE[difficulty];
125
+ const overrides = options.difficulties?.[difficulty];
126
+ const config = overrides ? { ...baseConfig, ...overrides } : baseConfig;
127
+ const topics = options.topics ?? DEFAULT_TOPICS;
128
+ const formats = options.formats ?? DEFAULT_FORMATS;
129
+ const topic = randomChoice(topics);
130
+ const format = randomChoice(formats);
131
+ const lineCount = FORMAT_LINE_COUNTS[format] ?? randomInt(config.lineCount[0], config.lineCount[1]);
132
+ // ASCII target: sum of first-char ASCII values, plausible range for uppercase/lowercase letters
133
+ // Each line starts with a letter (65-122), so target is roughly lineCount * 65..122
134
+ const asciiTarget = randomInt(lineCount * 65, lineCount * 122);
135
+ const challenge = {
136
+ topic,
137
+ format,
138
+ lineCount,
139
+ asciiTarget,
140
+ timeLimitSeconds: config.timeLimitSeconds,
141
+ difficulty,
142
+ createdAt: Date.now(),
143
+ };
144
+ if (config.useWordCount) {
145
+ // Target word count: reasonable range for the format
146
+ challenge.wordCount = randomInt(lineCount * 3, lineCount * 7);
147
+ }
148
+ if (config.useCharPosition) {
149
+ // Pick a character position in flattened text and require a specific letter
150
+ const pos = randomInt(5, 30);
151
+ const charCode = randomInt(97, 122); // lowercase a-z
152
+ challenge.charPosition = [pos, String.fromCharCode(charCode)];
153
+ }
154
+ if (config.useTotalChars) {
155
+ // Total character count (excluding newlines)
156
+ challenge.totalChars = randomInt(lineCount * 15, lineCount * 40);
157
+ }
158
+ const challengeToken = signToken(challenge, options.secret);
159
+ return { challenge, challengeToken };
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // Server-side: solution verification
163
+ // ---------------------------------------------------------------------------
164
+ /**
165
+ * Verify a captcha solution against a signed challenge token.
166
+ *
167
+ * @param challengeToken HMAC-signed token from createCaptchaChallenge
168
+ * @param solution The agent's solution (text + timestamp)
169
+ * @param secret HMAC secret used to sign the token
170
+ * @param options Verification options (timing, tolerance, replay protection, redaction)
171
+ * @returns Verification result, or `null` if the token is invalid/replayed
172
+ */
173
+ export async function verifyCaptchaSolution(challengeToken, solution, secret, options) {
174
+ const payload = verifyToken(challengeToken, secret);
175
+ if (!payload)
176
+ return null;
177
+ // One-time use: consume the challenge token if a store is provided
178
+ if (options?.consumeChallenge) {
179
+ const consumed = await options.consumeChallenge(challengeToken);
180
+ if (!consumed)
181
+ return null; // replay rejected
182
+ }
183
+ const challenge = payload;
184
+ const lines = solution.text.split('\n').filter(l => l.length > 0);
185
+ const flatText = lines.join('');
186
+ const reveal = options?.revealConstraints ?? true;
187
+ const asciiTolerance = options?.asciiTolerance ?? 0;
188
+ // 1. ASCII sum of first characters
189
+ const firstChars = lines.map(l => l.charCodeAt(0));
190
+ const actualAsciiSum = firstChars.reduce((sum, c) => sum + c, 0);
191
+ const asciiPass = Math.abs(actualAsciiSum - challenge.asciiTarget) <= asciiTolerance;
192
+ const result = {
193
+ asciiSum: {
194
+ pass: asciiPass,
195
+ actual: reveal ? actualAsciiSum : 0,
196
+ target: reveal ? challenge.asciiTarget : 0,
197
+ },
198
+ timing: { pass: false, elapsedSeconds: 0 },
199
+ overallPass: false,
200
+ verdict: 'CHALLENGE_FAILED',
201
+ };
202
+ // 2. Line count check
203
+ const lineCountPass = lines.length === challenge.lineCount;
204
+ result.lineCount = {
205
+ pass: lineCountPass,
206
+ actual: reveal ? lines.length : 0,
207
+ target: reveal ? challenge.lineCount : 0,
208
+ };
209
+ // 3. Word count
210
+ let wordCountPass = true;
211
+ if (challenge.wordCount !== undefined) {
212
+ const words = solution.text.split(/\s+/).filter(w => w.length > 0);
213
+ const actualWordCount = words.length;
214
+ wordCountPass = actualWordCount === challenge.wordCount;
215
+ result.wordCount = {
216
+ pass: wordCountPass,
217
+ actual: reveal ? actualWordCount : 0,
218
+ target: reveal ? challenge.wordCount : 0,
219
+ };
220
+ }
221
+ // 4. Character at position
222
+ let charPositionPass = true;
223
+ if (challenge.charPosition) {
224
+ const [pos, expectedChar] = challenge.charPosition;
225
+ const actualChar = flatText[pos] ?? '';
226
+ charPositionPass = actualChar === expectedChar;
227
+ result.charPosition = { pass: charPositionPass };
228
+ }
229
+ // 5. Total chars (excluding newlines)
230
+ let totalCharsPass = true;
231
+ if (challenge.totalChars !== undefined) {
232
+ const actualTotalChars = flatText.length;
233
+ totalCharsPass = actualTotalChars === challenge.totalChars;
234
+ result.totalChars = {
235
+ pass: totalCharsPass,
236
+ actual: reveal ? actualTotalChars : 0,
237
+ target: reveal ? challenge.totalChars : 0,
238
+ };
239
+ }
240
+ // 6. Timing — use server wall-clock by default, fall back to client solvedAt
241
+ const useServerTiming = options?.useServerTiming ?? true;
242
+ const toleranceSeconds = options?.timingToleranceSeconds ?? 2;
243
+ const elapsedMs = useServerTiming
244
+ ? (Date.now() - challenge.createdAt)
245
+ : (solution.solvedAt - challenge.createdAt);
246
+ const elapsedSeconds = elapsedMs / 1000;
247
+ const timingPass = elapsedSeconds >= 0 && elapsedSeconds <= (challenge.timeLimitSeconds + toleranceSeconds);
248
+ result.timing = { pass: timingPass, elapsedSeconds };
249
+ // Overall
250
+ result.overallPass = asciiPass && lineCountPass && wordCountPass && charPositionPass && totalCharsPass && timingPass;
251
+ result.verdict = result.overallPass ? 'VERIFIED_AI_AGENT' : 'CHALLENGE_FAILED';
252
+ return result;
253
+ }
254
+ // ---------------------------------------------------------------------------
255
+ // Agent-side: sign-in captcha helper
256
+ // ---------------------------------------------------------------------------
257
+ /**
258
+ * Detect and solve a captcha challenge from a nonce response.
259
+ *
260
+ * When a server's nonce endpoint returns `{ status: 'captcha_required' }`,
261
+ * this helper calls the solver, packs the response, and returns a
262
+ * `challengeResponse` string for the agent to include in the retry request.
263
+ *
264
+ * @param nonceResponse The parsed JSON body from the nonce endpoint
265
+ * @param solver Callback that generates solution text from a challenge
266
+ * @returns `{ solved: true, challengeResponse }` if captcha was solved,
267
+ * `{ solved: false }` if no captcha was required
268
+ *
269
+ * @example
270
+ * ```typescript
271
+ * import { solveCaptchaChallenge } from '@buildersgarden/siwa/captcha';
272
+ *
273
+ * const res = await fetch('/siwa/nonce', { method: 'POST', body: JSON.stringify(params) });
274
+ * const nonceResult = await res.json();
275
+ *
276
+ * const captcha = await solveCaptchaChallenge(nonceResult, async (challenge) => {
277
+ * // LLM generates text satisfying all constraints
278
+ * return await generateText(challenge); // your LLM solver
279
+ * });
280
+ *
281
+ * if (captcha.solved) {
282
+ * // Retry with challenge response
283
+ * const retry = await fetch('/siwa/nonce', {
284
+ * method: 'POST',
285
+ * body: JSON.stringify({ ...params, challengeResponse: captcha.challengeResponse }),
286
+ * });
287
+ * }
288
+ * ```
289
+ */
290
+ export async function solveCaptchaChallenge(nonceResponse, solver) {
291
+ if (nonceResponse.status !== 'captcha_required' ||
292
+ !nonceResponse.challenge ||
293
+ !nonceResponse.challengeToken) {
294
+ return { solved: false };
295
+ }
296
+ const text = await solver(nonceResponse.challenge);
297
+ const challengeResponse = packCaptchaResponse(nonceResponse.challengeToken, text);
298
+ return { solved: true, challengeResponse };
299
+ }
300
+ // ---------------------------------------------------------------------------
301
+ // Agent-side: pack / unpack response
302
+ // ---------------------------------------------------------------------------
303
+ /**
304
+ * Package a captcha solution for submission (agent-side).
305
+ *
306
+ * @param challengeToken The challenge token received from the server
307
+ * @param text The agent's solution text
308
+ * @returns A packed string to send in the challenge response header/field
309
+ */
310
+ export function packCaptchaResponse(challengeToken, text) {
311
+ const solution = {
312
+ text,
313
+ solvedAt: Date.now(),
314
+ };
315
+ const payload = Buffer.from(JSON.stringify({ challengeToken, solution })).toString('base64url');
316
+ return payload;
317
+ }
318
+ /**
319
+ * Unpack a captcha response (server-side).
320
+ *
321
+ * @param packed The base64url-encoded response from the agent
322
+ * @returns The challenge token and solution, or `null` if malformed
323
+ */
324
+ export function unpackCaptchaResponse(packed) {
325
+ try {
326
+ const decoded = JSON.parse(Buffer.from(packed, 'base64url').toString());
327
+ if (!decoded.challengeToken || !decoded.solution?.text || typeof decoded.solution?.solvedAt !== 'number') {
328
+ return null;
329
+ }
330
+ return { challengeToken: decoded.challengeToken, solution: decoded.solution };
331
+ }
332
+ catch {
333
+ return null;
334
+ }
335
+ }
package/dist/erc8128.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  import type { Address, PublicClient } from 'viem';
13
13
  import { type EthHttpSigner, type NonceStore } from '@slicekit/erc8128';
14
14
  import type { Signer, SignerType } from './signer/index.js';
15
+ import { type CaptchaChallenge, type CaptchaPolicy, type CaptchaOptions, type CaptchaSolver } from './captcha.js';
15
16
  export interface VerifyOptions {
16
17
  receiptSecret: string;
17
18
  rpcUrl?: string;
@@ -19,6 +20,8 @@ export interface VerifyOptions {
19
20
  publicClient?: PublicClient;
20
21
  nonceStore?: NonceStore;
21
22
  allowedSignerTypes?: SignerType[];
23
+ captchaPolicy?: CaptchaPolicy;
24
+ captchaOptions?: CaptchaOptions;
22
25
  }
23
26
  /** Verified agent identity returned from a successful auth check. */
24
27
  export interface SiwaAgent {
@@ -34,6 +37,12 @@ export type AuthResult = {
34
37
  } | {
35
38
  valid: false;
36
39
  error: string;
40
+ } | {
41
+ valid: false;
42
+ error: string;
43
+ captchaRequired: true;
44
+ challenge: CaptchaChallenge;
45
+ challengeToken: string;
37
46
  };
38
47
  /**
39
48
  * Resolve the receipt secret from an explicit value or environment variables.
@@ -98,6 +107,65 @@ export declare function attachReceipt(request: Request, receipt: string): Reques
98
107
  export declare function signAuthenticatedRequest(request: Request, receipt: string, signer: Signer, chainId: number, options?: {
99
108
  signerAddress?: Address;
100
109
  }): Promise<Request>;
110
+ /**
111
+ * Detect a captcha challenge in a server response and build a re-signed retry request.
112
+ *
113
+ * When a server returns 401 with `{ captchaRequired: true, challenge, challengeToken }`,
114
+ * this function:
115
+ * 1. Extracts the challenge from the response body
116
+ * 2. Calls the solver to generate solution text
117
+ * 3. Packs the solution into the `X-SIWA-Challenge-Response` header
118
+ * 4. Re-signs the request with ERC-8128 (binding the challenge response into the signature)
119
+ * 5. Returns the new signed request ready for retry
120
+ *
121
+ * **Important:** The `request` parameter must be a fresh, unconsumed Request
122
+ * (not the one already sent via `fetch`). Keep the original URL, method, headers,
123
+ * and body around so you can reconstruct it for the retry.
124
+ *
125
+ * @param response The server's 401 response (body is read via `.clone()`, so the original stays readable)
126
+ * @param request A fresh, unconsumed Request matching the original call
127
+ * @param receipt Verification receipt from SIWA sign-in
128
+ * @param signer A SIWA Signer
129
+ * @param chainId Chain ID for the ERC-8128 keyid
130
+ * @param solver Callback that generates solution text from a challenge
131
+ * @param options Optional overrides (e.g. signerAddress for TBA identity)
132
+ * @returns `{ retry: true, request }` with the signed retry request, or `{ retry: false }` if no captcha
133
+ *
134
+ * @example
135
+ * ```typescript
136
+ * import { signAuthenticatedRequest, retryWithCaptcha } from '@buildersgarden/siwa/erc8128';
137
+ *
138
+ * const url = 'https://api.example.com/action';
139
+ * const body = JSON.stringify({ key: 'value' });
140
+ *
141
+ * // First attempt
142
+ * const signed = await signAuthenticatedRequest(
143
+ * new Request(url, { method: 'POST', body }),
144
+ * receipt, signer, 84532,
145
+ * );
146
+ * const response = await fetch(signed);
147
+ *
148
+ * // Handle captcha if required
149
+ * const result = await retryWithCaptcha(
150
+ * response,
151
+ * new Request(url, { method: 'POST', body }), // fresh request
152
+ * receipt, signer, 84532,
153
+ * async (challenge) => generateText(challenge), // your LLM solver
154
+ * );
155
+ *
156
+ * if (result.retry) {
157
+ * const retryResponse = await fetch(result.request);
158
+ * }
159
+ * ```
160
+ */
161
+ export declare function retryWithCaptcha(response: Response, request: Request, receipt: string, signer: Signer, chainId: number, solver: CaptchaSolver, options?: {
162
+ signerAddress?: Address;
163
+ }): Promise<{
164
+ retry: true;
165
+ request: Request;
166
+ } | {
167
+ retry: false;
168
+ }>;
101
169
  /**
102
170
  * Verify an authenticated request: ERC-8128 signature + receipt + optional onchain check.
103
171
  *
package/dist/erc8128.js CHANGED
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { signRequest, verifyRequest, } from '@slicekit/erc8128';
13
13
  import { verifyReceipt } from './receipt.js';
14
+ import { createCaptchaChallenge, unpackCaptchaResponse, verifyCaptchaSolution, packCaptchaResponse, CHALLENGE_RESPONSE_HEADER, } from './captcha.js';
14
15
  /**
15
16
  * Resolve the receipt secret from an explicit value or environment variables.
16
17
  *
@@ -111,6 +112,90 @@ export async function signAuthenticatedRequest(request, receipt, signer, chainId
111
112
  });
112
113
  }
113
114
  // ---------------------------------------------------------------------------
115
+ // Agent-side: captcha retry helper
116
+ // ---------------------------------------------------------------------------
117
+ /**
118
+ * Detect a captcha challenge in a server response and build a re-signed retry request.
119
+ *
120
+ * When a server returns 401 with `{ captchaRequired: true, challenge, challengeToken }`,
121
+ * this function:
122
+ * 1. Extracts the challenge from the response body
123
+ * 2. Calls the solver to generate solution text
124
+ * 3. Packs the solution into the `X-SIWA-Challenge-Response` header
125
+ * 4. Re-signs the request with ERC-8128 (binding the challenge response into the signature)
126
+ * 5. Returns the new signed request ready for retry
127
+ *
128
+ * **Important:** The `request` parameter must be a fresh, unconsumed Request
129
+ * (not the one already sent via `fetch`). Keep the original URL, method, headers,
130
+ * and body around so you can reconstruct it for the retry.
131
+ *
132
+ * @param response The server's 401 response (body is read via `.clone()`, so the original stays readable)
133
+ * @param request A fresh, unconsumed Request matching the original call
134
+ * @param receipt Verification receipt from SIWA sign-in
135
+ * @param signer A SIWA Signer
136
+ * @param chainId Chain ID for the ERC-8128 keyid
137
+ * @param solver Callback that generates solution text from a challenge
138
+ * @param options Optional overrides (e.g. signerAddress for TBA identity)
139
+ * @returns `{ retry: true, request }` with the signed retry request, or `{ retry: false }` if no captcha
140
+ *
141
+ * @example
142
+ * ```typescript
143
+ * import { signAuthenticatedRequest, retryWithCaptcha } from '@buildersgarden/siwa/erc8128';
144
+ *
145
+ * const url = 'https://api.example.com/action';
146
+ * const body = JSON.stringify({ key: 'value' });
147
+ *
148
+ * // First attempt
149
+ * const signed = await signAuthenticatedRequest(
150
+ * new Request(url, { method: 'POST', body }),
151
+ * receipt, signer, 84532,
152
+ * );
153
+ * const response = await fetch(signed);
154
+ *
155
+ * // Handle captcha if required
156
+ * const result = await retryWithCaptcha(
157
+ * response,
158
+ * new Request(url, { method: 'POST', body }), // fresh request
159
+ * receipt, signer, 84532,
160
+ * async (challenge) => generateText(challenge), // your LLM solver
161
+ * );
162
+ *
163
+ * if (result.retry) {
164
+ * const retryResponse = await fetch(result.request);
165
+ * }
166
+ * ```
167
+ */
168
+ export async function retryWithCaptcha(response, request, receipt, signer, chainId, solver, options) {
169
+ // Only handle 401 responses
170
+ if (response.status !== 401)
171
+ return { retry: false };
172
+ // Read from a clone so the original response stays readable for the caller
173
+ let body;
174
+ try {
175
+ body = await response.clone().json();
176
+ }
177
+ catch {
178
+ return { retry: false };
179
+ }
180
+ if (!body.captchaRequired || !body.challenge || !body.challengeToken) {
181
+ return { retry: false };
182
+ }
183
+ // Solve the challenge
184
+ const text = await solver(body.challenge);
185
+ const packed = packCaptchaResponse(body.challengeToken, text);
186
+ // Rebuild the request with receipt + challenge response headers
187
+ const headers = new Headers(request.headers);
188
+ headers.set(RECEIPT_HEADER, receipt);
189
+ headers.set(CHALLENGE_RESPONSE_HEADER, packed);
190
+ const rebuiltRequest = new Request(request, { headers });
191
+ // Create ERC-8128 signer and sign with both headers bound into the signature
192
+ const erc8128Signer = await createErc8128Signer(signer, chainId, options);
193
+ const signedRequest = await signRequest(rebuiltRequest, erc8128Signer, {
194
+ components: [RECEIPT_HEADER, CHALLENGE_RESPONSE_HEADER],
195
+ });
196
+ return { retry: true, request: signedRequest };
197
+ }
198
+ // ---------------------------------------------------------------------------
114
199
  // Server-side: high-level request verification
115
200
  // ---------------------------------------------------------------------------
116
201
  /**
@@ -226,6 +311,48 @@ export async function verifyAuthenticatedRequest(request, options) {
226
311
  return { valid: false, error: 'Onchain ownership check failed: agent not registered' };
227
312
  }
228
313
  }
314
+ // 5. Captcha evaluation (after successful auth, server-defined policy)
315
+ if (options.captchaPolicy) {
316
+ const captchaSecret = options.captchaOptions?.secret ?? options.receiptSecret;
317
+ const difficulty = await options.captchaPolicy({
318
+ address: receipt.address,
319
+ agentId: receipt.agentId,
320
+ agentRegistry: receipt.agentRegistry,
321
+ request,
322
+ });
323
+ if (difficulty) {
324
+ const responseHeader = request.headers.get(CHALLENGE_RESPONSE_HEADER);
325
+ if (responseHeader) {
326
+ // Agent submitted a challenge response — verify it
327
+ const unpacked = unpackCaptchaResponse(responseHeader);
328
+ if (!unpacked) {
329
+ return { valid: false, error: 'Invalid captcha response format' };
330
+ }
331
+ const verification = await verifyCaptchaSolution(unpacked.challengeToken, unpacked.solution, captchaSecret, options.captchaOptions?.verify);
332
+ if (!verification || !verification.overallPass) {
333
+ return { valid: false, error: `Captcha verification failed: ${verification?.verdict ?? 'invalid token'}` };
334
+ }
335
+ // Captcha passed — fall through to return success
336
+ }
337
+ else {
338
+ // No challenge response — issue a new challenge
339
+ const captchaOpts = {
340
+ secret: captchaSecret,
341
+ topics: options.captchaOptions?.topics,
342
+ formats: options.captchaOptions?.formats,
343
+ difficulties: options.captchaOptions?.difficulties,
344
+ };
345
+ const { challenge, challengeToken } = createCaptchaChallenge(difficulty, captchaOpts);
346
+ return {
347
+ valid: false,
348
+ error: 'Captcha required',
349
+ captchaRequired: true,
350
+ challenge,
351
+ challengeToken,
352
+ };
353
+ }
354
+ }
355
+ }
229
356
  return {
230
357
  valid: true,
231
358
  agent: {
package/dist/index.d.ts CHANGED
@@ -9,5 +9,6 @@ export * from './receipt.js';
9
9
  export * from './erc8128.js';
10
10
  export * from './nonce-store.js';
11
11
  export * from './tba.js';
12
+ export * from './captcha.js';
12
13
  export * from './x402.js';
13
14
  export * from './client-resolver.js';
package/dist/index.js CHANGED
@@ -9,5 +9,6 @@ export * from './receipt.js';
9
9
  export * from './erc8128.js';
10
10
  export * from './nonce-store.js';
11
11
  export * from './tba.js';
12
+ export * from './captcha.js';
12
13
  export * from './x402.js';
13
14
  export * from './client-resolver.js';
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import type { RequestHandler } from 'express';
18
18
  import { type SiwaAgent, type VerifyOptions } from '../erc8128.js';
19
+ import { type CaptchaPolicy, type CaptchaOptions } from '../captcha.js';
19
20
  import type { SignerType } from '../signer/index.js';
20
21
  import { type X402Config, type X402Payment } from '../x402.js';
21
22
  export type { SiwaAgent };
@@ -39,6 +40,10 @@ export interface SiwaMiddlewareOptions {
39
40
  publicClient?: VerifyOptions['publicClient'];
40
41
  /** Allowed signer types. Omit to accept all. */
41
42
  allowedSignerTypes?: SignerType[];
43
+ /** Captcha policy for per-request challenges. */
44
+ captchaPolicy?: CaptchaPolicy;
45
+ /** Captcha options (secret, topics, formats). Secret defaults to receiptSecret. */
46
+ captchaOptions?: CaptchaOptions;
42
47
  /** Optional x402 payment gate. When set, both SIWA auth AND a valid payment are required. */
43
48
  x402?: X402Config;
44
49
  }
@@ -77,3 +82,35 @@ export declare function siwaJsonParser(): RequestHandler;
77
82
  * - Both succeed → `req.agent` + `req.payment` → `next()`
78
83
  */
79
84
  export declare function siwaMiddleware(options?: SiwaMiddlewareOptions): RequestHandler;
85
+ /**
86
+ * Create a pre-configured `siwaMiddleware` with shared defaults.
87
+ *
88
+ * Use this to apply the same captcha policy and options globally,
89
+ * with optional per-route overrides.
90
+ *
91
+ * @param defaults Shared options applied to all routes
92
+ * @returns A `siwaMiddleware`-like function that bakes in the defaults
93
+ *
94
+ * @example
95
+ * ```typescript
96
+ * import { createSiwaMiddleware, siwaJsonParser, siwaCors } from "@buildersgarden/siwa/express";
97
+ *
98
+ * const auth = createSiwaMiddleware({
99
+ * captchaPolicy: async ({ address }) => {
100
+ * const known = await db.agents.exists(address);
101
+ * return known ? null : 'medium';
102
+ * },
103
+ * captchaOptions: { secret: process.env.SIWA_SECRET! },
104
+ * });
105
+ *
106
+ * app.use(siwaJsonParser());
107
+ * app.use(siwaCors());
108
+ *
109
+ * // Apply globally
110
+ * app.use(auth());
111
+ *
112
+ * // Or per-route with overrides
113
+ * app.post('/api/transfer', auth({ captchaPolicy: () => 'hard' }), handler);
114
+ * ```
115
+ */
116
+ export declare function createSiwaMiddleware(defaults: SiwaMiddlewareOptions): (overrides?: Partial<SiwaMiddlewareOptions>) => RequestHandler;
@@ -16,11 +16,12 @@
16
16
  */
17
17
  import express from 'express';
18
18
  import { verifyAuthenticatedRequest, expressToFetchRequest, resolveReceiptSecret, } from '../erc8128.js';
19
+ import { CHALLENGE_HEADER } from '../captcha.js';
19
20
  import { X402_HEADERS, encodeX402Header, decodeX402Header, processX402Payment, } from '../x402.js';
20
21
  // ---------------------------------------------------------------------------
21
22
  // CORS middleware
22
23
  // ---------------------------------------------------------------------------
23
- const DEFAULT_SIWA_HEADERS = 'Content-Type, X-SIWA-Receipt, Signature, Signature-Input, Content-Digest';
24
+ const DEFAULT_SIWA_HEADERS = 'Content-Type, X-SIWA-Receipt, X-SIWA-Challenge-Response, Signature, Signature-Input, Content-Digest';
24
25
  const X402_CORS_HEADERS = `${X402_HEADERS.PAYMENT_SIGNATURE}, ${X402_HEADERS.PAYMENT_REQUIRED}`;
25
26
  const X402_EXPOSE_HEADERS = `${X402_HEADERS.PAYMENT_REQUIRED}, ${X402_HEADERS.PAYMENT_RESPONSE}`;
26
27
  /**
@@ -36,13 +37,14 @@ export function siwaCors(options) {
36
37
  if (options?.x402) {
37
38
  headers = `${headers}, ${X402_CORS_HEADERS}`;
38
39
  }
40
+ const exposeHeaders = options?.x402
41
+ ? `X-SIWA-Challenge, ${X402_EXPOSE_HEADERS}`
42
+ : 'X-SIWA-Challenge';
39
43
  return (req, res, next) => {
40
44
  res.header('Access-Control-Allow-Origin', origin);
41
45
  res.header('Access-Control-Allow-Methods', methods);
42
46
  res.header('Access-Control-Allow-Headers', headers);
43
- if (options?.x402) {
44
- res.header('Access-Control-Expose-Headers', X402_EXPOSE_HEADERS);
45
- }
47
+ res.header('Access-Control-Expose-Headers', exposeHeaders);
46
48
  if (req.method === 'OPTIONS') {
47
49
  res.sendStatus(204);
48
50
  return;
@@ -100,8 +102,20 @@ export function siwaMiddleware(options) {
100
102
  verifyOnchain: options?.verifyOnchain,
101
103
  publicClient: options?.publicClient,
102
104
  allowedSignerTypes: options?.allowedSignerTypes,
105
+ captchaPolicy: options?.captchaPolicy,
106
+ captchaOptions: options?.captchaOptions,
103
107
  });
104
108
  if (!result.valid) {
109
+ if ('captchaRequired' in result && result.captchaRequired) {
110
+ res.header(CHALLENGE_HEADER, result.challengeToken);
111
+ res.status(401).json({
112
+ error: result.error,
113
+ challenge: result.challenge,
114
+ challengeToken: result.challengeToken,
115
+ captchaRequired: true,
116
+ });
117
+ return;
118
+ }
105
119
  res.status(401).json({ error: result.error });
106
120
  return;
107
121
  }
@@ -167,3 +181,40 @@ export function siwaMiddleware(options) {
167
181
  next();
168
182
  };
169
183
  }
184
+ // ---------------------------------------------------------------------------
185
+ // Factory: pre-configured middleware
186
+ // ---------------------------------------------------------------------------
187
+ /**
188
+ * Create a pre-configured `siwaMiddleware` with shared defaults.
189
+ *
190
+ * Use this to apply the same captcha policy and options globally,
191
+ * with optional per-route overrides.
192
+ *
193
+ * @param defaults Shared options applied to all routes
194
+ * @returns A `siwaMiddleware`-like function that bakes in the defaults
195
+ *
196
+ * @example
197
+ * ```typescript
198
+ * import { createSiwaMiddleware, siwaJsonParser, siwaCors } from "@buildersgarden/siwa/express";
199
+ *
200
+ * const auth = createSiwaMiddleware({
201
+ * captchaPolicy: async ({ address }) => {
202
+ * const known = await db.agents.exists(address);
203
+ * return known ? null : 'medium';
204
+ * },
205
+ * captchaOptions: { secret: process.env.SIWA_SECRET! },
206
+ * });
207
+ *
208
+ * app.use(siwaJsonParser());
209
+ * app.use(siwaCors());
210
+ *
211
+ * // Apply globally
212
+ * app.use(auth());
213
+ *
214
+ * // Or per-route with overrides
215
+ * app.post('/api/transfer', auth({ captchaPolicy: () => 'hard' }), handler);
216
+ * ```
217
+ */
218
+ export function createSiwaMiddleware(defaults) {
219
+ return (overrides) => siwaMiddleware({ ...defaults, ...overrides });
220
+ }
@@ -17,6 +17,7 @@
17
17
  * ```
18
18
  */
19
19
  import { type SiwaAgent } from '../erc8128.js';
20
+ import { type CaptchaPolicy, type CaptchaOptions } from '../captcha.js';
20
21
  import type { SignerType } from '../signer/index.js';
21
22
  import { type X402Config, type X402Payment } from '../x402.js';
22
23
  export type { SiwaAgent, X402Payment };
@@ -29,6 +30,10 @@ export interface WithSiwaOptions {
29
30
  verifyOnchain?: boolean;
30
31
  /** Allowed signer types. Omit to accept all. */
31
32
  allowedSignerTypes?: SignerType[];
33
+ /** Captcha policy for per-request challenges. */
34
+ captchaPolicy?: CaptchaPolicy;
35
+ /** Captcha options (secret, topics, formats). Secret defaults to receiptSecret. */
36
+ captchaOptions?: CaptchaOptions;
32
37
  /** Optional x402 payment gate. When set, both SIWA auth AND a valid payment are required. */
33
38
  x402?: X402Config;
34
39
  }
@@ -17,11 +17,12 @@
17
17
  * ```
18
18
  */
19
19
  import { verifyAuthenticatedRequest, nextjsToFetchRequest, resolveReceiptSecret, } from '../erc8128.js';
20
+ import { CHALLENGE_HEADER } from '../captcha.js';
20
21
  import { X402_HEADERS, encodeX402Header, decodeX402Header, processX402Payment, } from '../x402.js';
21
22
  // ---------------------------------------------------------------------------
22
23
  // CORS helpers
23
24
  // ---------------------------------------------------------------------------
24
- const DEFAULT_SIWA_HEADERS = 'Content-Type, X-SIWA-Receipt, Signature, Signature-Input, Content-Digest';
25
+ const DEFAULT_SIWA_HEADERS = 'Content-Type, X-SIWA-Receipt, X-SIWA-Challenge-Response, Signature, Signature-Input, Content-Digest';
25
26
  const X402_CORS_HEADERS = `${X402_HEADERS.PAYMENT_SIGNATURE}, ${X402_HEADERS.PAYMENT_REQUIRED}`;
26
27
  const X402_EXPOSE_HEADERS = `${X402_HEADERS.PAYMENT_REQUIRED}, ${X402_HEADERS.PAYMENT_RESPONSE}`;
27
28
  /** CORS headers required by SIWA-authenticated requests. */
@@ -76,9 +77,21 @@ export function withSiwa(handler, options) {
76
77
  rpcUrl: options?.rpcUrl,
77
78
  verifyOnchain: options?.verifyOnchain,
78
79
  allowedSignerTypes: options?.allowedSignerTypes,
80
+ captchaPolicy: options?.captchaPolicy,
81
+ captchaOptions: options?.captchaOptions,
79
82
  };
80
83
  const result = await verifyAuthenticatedRequest(nextjsToFetchRequest(verifyReq), verifyOptions);
81
84
  if (!result.valid) {
85
+ if ('captchaRequired' in result && result.captchaRequired) {
86
+ const response = corsJson({
87
+ error: result.error,
88
+ challenge: result.challenge,
89
+ challengeToken: result.challengeToken,
90
+ captchaRequired: true,
91
+ }, { status: 401 }, corsOpts);
92
+ response.headers.set(CHALLENGE_HEADER, result.challengeToken);
93
+ return response;
94
+ }
82
95
  return corsJson({ error: result.error }, { status: 401 }, corsOpts);
83
96
  }
84
97
  // -----------------------------------------------------------------
package/dist/siwa.d.ts CHANGED
@@ -11,6 +11,7 @@ import { type PublicClient } from 'viem';
11
11
  import { AgentProfile, ServiceType, TrustModel } from './registry.js';
12
12
  import type { Signer, SignerType } from './signer/index.js';
13
13
  import type { SIWANonceStore } from './nonce-store.js';
14
+ import { type CaptchaChallenge, type CaptchaPolicy, type CaptchaOptions } from './captcha.js';
14
15
  export declare enum SIWAErrorCode {
15
16
  INVALID_SIGNATURE = "INVALID_SIGNATURE",
16
17
  DOMAIN_MISMATCH = "DOMAIN_MISMATCH",
@@ -25,7 +26,9 @@ export declare enum SIWAErrorCode {
25
26
  MISSING_TRUST_MODEL = "MISSING_TRUST_MODEL",
26
27
  LOW_REPUTATION = "LOW_REPUTATION",
27
28
  CUSTOM_CHECK_FAILED = "CUSTOM_CHECK_FAILED",
28
- VERIFICATION_FAILED = "VERIFICATION_FAILED"
29
+ VERIFICATION_FAILED = "VERIFICATION_FAILED",
30
+ CAPTCHA_REQUIRED = "CAPTCHA_REQUIRED",
31
+ CAPTCHA_FAILED = "CAPTCHA_FAILED"
29
32
  }
30
33
  export interface SIWAMessageFields {
31
34
  domain: string;
@@ -114,11 +117,14 @@ export interface SIWANonceParams {
114
117
  address: string;
115
118
  agentId: number;
116
119
  agentRegistry: string;
120
+ challengeResponse?: string;
117
121
  }
118
122
  export interface SIWANonceOptions {
119
123
  expirationTTL?: number;
120
124
  secret?: string;
121
125
  nonceStore?: SIWANonceStore;
126
+ captchaPolicy?: CaptchaPolicy;
127
+ captchaOptions?: CaptchaOptions;
122
128
  }
123
129
  export type SIWANonceResult = {
124
130
  status: 'nonce_issued';
@@ -126,6 +132,10 @@ export type SIWANonceResult = {
126
132
  nonceToken?: string;
127
133
  issuedAt: string;
128
134
  expirationTime: string;
135
+ } | {
136
+ status: 'captcha_required';
137
+ challenge: CaptchaChallenge;
138
+ challengeToken: string;
129
139
  } | SIWAResponse;
130
140
  /**
131
141
  * Validate agent registration and create a SIWA nonce.
package/dist/siwa.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import * as crypto from 'crypto';
11
11
  import { getAgent, getReputation } from './registry.js';
12
+ import { createCaptchaChallenge, unpackCaptchaResponse, verifyCaptchaSolution, } from './captcha.js';
12
13
  // ─── Types ───────────────────────────────────────────────────────────
13
14
  export var SIWAErrorCode;
14
15
  (function (SIWAErrorCode) {
@@ -26,6 +27,8 @@ export var SIWAErrorCode;
26
27
  SIWAErrorCode["LOW_REPUTATION"] = "LOW_REPUTATION";
27
28
  SIWAErrorCode["CUSTOM_CHECK_FAILED"] = "CUSTOM_CHECK_FAILED";
28
29
  SIWAErrorCode["VERIFICATION_FAILED"] = "VERIFICATION_FAILED";
30
+ SIWAErrorCode["CAPTCHA_REQUIRED"] = "CAPTCHA_REQUIRED";
31
+ SIWAErrorCode["CAPTCHA_FAILED"] = "CAPTCHA_FAILED";
29
32
  })(SIWAErrorCode || (SIWAErrorCode = {}));
30
33
  /**
31
34
  * Convert a SIWAVerificationResult into a standard SIWAResponse
@@ -268,6 +271,57 @@ export async function createSIWANonce(params, client, options) {
268
271
  error: 'Signer is not the owner of this agent NFT',
269
272
  });
270
273
  }
274
+ // Captcha evaluation (after ownership check, before nonce issuance)
275
+ if (options?.captchaPolicy) {
276
+ const captchaSecret = options.captchaOptions?.secret ?? options.secret;
277
+ if (!captchaSecret) {
278
+ throw new Error('captchaPolicy requires a secret — set captchaOptions.secret or options.secret');
279
+ }
280
+ const difficulty = await options.captchaPolicy({ address, agentId, agentRegistry });
281
+ if (difficulty) {
282
+ // Check if the agent submitted a challenge response
283
+ if (params.challengeResponse) {
284
+ const unpacked = unpackCaptchaResponse(params.challengeResponse);
285
+ if (!unpacked) {
286
+ return buildSIWAResponse({
287
+ valid: false,
288
+ address,
289
+ agentId,
290
+ agentRegistry,
291
+ chainId,
292
+ verified: 'onchain',
293
+ code: SIWAErrorCode.CAPTCHA_FAILED,
294
+ error: 'Invalid captcha response format',
295
+ });
296
+ }
297
+ const verification = await verifyCaptchaSolution(unpacked.challengeToken, unpacked.solution, captchaSecret, options.captchaOptions?.verify);
298
+ if (!verification || !verification.overallPass) {
299
+ return buildSIWAResponse({
300
+ valid: false,
301
+ address,
302
+ agentId,
303
+ agentRegistry,
304
+ chainId,
305
+ verified: 'onchain',
306
+ code: SIWAErrorCode.CAPTCHA_FAILED,
307
+ error: `Captcha verification failed: ${verification?.verdict ?? 'invalid token'}`,
308
+ });
309
+ }
310
+ // Captcha passed — fall through to issue nonce
311
+ }
312
+ else {
313
+ // No response yet — issue a challenge
314
+ const captchaOpts = {
315
+ secret: captchaSecret,
316
+ topics: options.captchaOptions?.topics,
317
+ formats: options.captchaOptions?.formats,
318
+ difficulties: options.captchaOptions?.difficulties,
319
+ };
320
+ const { challenge, challengeToken } = createCaptchaChallenge(difficulty, captchaOpts);
321
+ return { status: 'captcha_required', challenge, challengeToken };
322
+ }
323
+ }
324
+ }
271
325
  // Agent is registered — issue the nonce
272
326
  const now = new Date();
273
327
  const expiresAt = new Date(now.getTime() + ttl);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buildersgarden/siwa",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -67,6 +67,10 @@
67
67
  "types": "./dist/tba.d.ts",
68
68
  "default": "./dist/tba.js"
69
69
  },
70
+ "./captcha": {
71
+ "types": "./dist/captcha.d.ts",
72
+ "default": "./dist/captcha.js"
73
+ },
70
74
  "./x402": {
71
75
  "types": "./dist/x402.d.ts",
72
76
  "default": "./dist/x402.js"