@buildersgarden/siwa 0.0.19 → 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.
- package/dist/addresses.js +5 -0
- package/dist/captcha.d.ts +188 -0
- package/dist/captcha.js +335 -0
- package/dist/client-resolver.d.ts +36 -0
- package/dist/client-resolver.js +92 -0
- package/dist/erc8128.d.ts +68 -0
- package/dist/erc8128.js +127 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/server-side-wrappers/express.d.ts +37 -0
- package/dist/server-side-wrappers/express.js +55 -4
- package/dist/server-side-wrappers/next.d.ts +5 -0
- package/dist/server-side-wrappers/next.js +14 -1
- package/dist/siwa.d.ts +11 -1
- package/dist/siwa.js +54 -0
- package/package.json +9 -1
package/dist/addresses.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// Identity Registries
|
|
9
9
|
// ---------------------------------------------------------------------------
|
|
10
10
|
export const REGISTRY_ADDRESSES = {
|
|
11
|
+
1: '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432', // Ethereum
|
|
11
12
|
8453: '0x8004A169FB4a3325136EB29fA0ceB6D2e539a432', // Base
|
|
12
13
|
84532: '0x8004A818BFB912233c491871b3d84c89A494BD9e', // Base Sepolia
|
|
13
14
|
11155111: '0x8004a6090Cd10A7288092483047B097295Fb8847', // ETH Sepolia
|
|
@@ -18,6 +19,7 @@ export const REGISTRY_ADDRESSES = {
|
|
|
18
19
|
// Reputation Registries
|
|
19
20
|
// ---------------------------------------------------------------------------
|
|
20
21
|
export const REPUTATION_ADDRESSES = {
|
|
22
|
+
1: '0x8004BAa17C55a88189AE136b182e5fdA19dE9b63', // Ethereum
|
|
21
23
|
8453: '0x8004BAa17C55a88189AE136b182e5fdA19dE9b63', // Base
|
|
22
24
|
84532: '0x8004B663056A597Dffe9eCcC1965A193B7388713', // Base Sepolia
|
|
23
25
|
};
|
|
@@ -25,6 +27,7 @@ export const REPUTATION_ADDRESSES = {
|
|
|
25
27
|
// RPC Endpoints (public, rate-limited)
|
|
26
28
|
// ---------------------------------------------------------------------------
|
|
27
29
|
export const RPC_ENDPOINTS = {
|
|
30
|
+
1: 'https://cloudflare-eth.com',
|
|
28
31
|
8453: 'https://mainnet.base.org',
|
|
29
32
|
84532: 'https://sepolia.base.org',
|
|
30
33
|
11155111: 'https://rpc.sepolia.org',
|
|
@@ -35,6 +38,7 @@ export const RPC_ENDPOINTS = {
|
|
|
35
38
|
// Chain Names
|
|
36
39
|
// ---------------------------------------------------------------------------
|
|
37
40
|
export const CHAIN_NAMES = {
|
|
41
|
+
1: 'Ethereum',
|
|
38
42
|
8453: 'Base',
|
|
39
43
|
84532: 'Base Sepolia',
|
|
40
44
|
11155111: 'Ethereum Sepolia',
|
|
@@ -54,6 +58,7 @@ export const FAUCETS = {
|
|
|
54
58
|
// Block Explorers
|
|
55
59
|
// ---------------------------------------------------------------------------
|
|
56
60
|
export const BLOCK_EXPLORERS = {
|
|
61
|
+
1: 'https://etherscan.io',
|
|
57
62
|
8453: 'https://basescan.org',
|
|
58
63
|
84532: 'https://sepolia.basescan.org',
|
|
59
64
|
11155111: 'https://sepolia.etherscan.io',
|
|
@@ -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;
|
package/dist/captcha.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client-resolver.ts
|
|
3
|
+
*
|
|
4
|
+
* Dynamic PublicClient resolution for multi-chain SIWA servers.
|
|
5
|
+
* Lazily creates and caches viem PublicClient instances per chain ID.
|
|
6
|
+
*/
|
|
7
|
+
import { type PublicClient } from 'viem';
|
|
8
|
+
export interface ClientResolverOptions {
|
|
9
|
+
/** Explicit RPC URL overrides per chain ID. */
|
|
10
|
+
rpcOverrides?: Record<number, string>;
|
|
11
|
+
/** Restrict which chain IDs are accepted. When set, only these chains can be resolved. */
|
|
12
|
+
allowedChainIds?: number[];
|
|
13
|
+
}
|
|
14
|
+
export interface ClientResolver {
|
|
15
|
+
/** Get (or lazily create) a PublicClient for the given chain ID. Throws if unsupported. */
|
|
16
|
+
getClient(chainId: number): PublicClient;
|
|
17
|
+
/** Check whether a chain ID can be resolved. */
|
|
18
|
+
isSupported(chainId: number): boolean;
|
|
19
|
+
/** List all chain IDs that can be resolved. */
|
|
20
|
+
supportedChainIds(): number[];
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Extract the chain ID from an `eip155:{chainId}:{address}` agent registry string.
|
|
24
|
+
* Returns `null` if the format is invalid.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseChainId(agentRegistry: string): number | null;
|
|
27
|
+
/**
|
|
28
|
+
* Create a ClientResolver that lazily creates and caches PublicClient instances.
|
|
29
|
+
*
|
|
30
|
+
* RPC resolution order:
|
|
31
|
+
* 1. Explicit `rpcOverrides` map
|
|
32
|
+
* 2. Environment variable `RPC_URL_{chainId}` (e.g. `RPC_URL_42161`)
|
|
33
|
+
* 3. Built-in `RPC_ENDPOINTS` from addresses.ts
|
|
34
|
+
* 4. Throw with a helpful error listing supported chains
|
|
35
|
+
*/
|
|
36
|
+
export declare function createClientResolver(options?: ClientResolverOptions): ClientResolver;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client-resolver.ts
|
|
3
|
+
*
|
|
4
|
+
* Dynamic PublicClient resolution for multi-chain SIWA servers.
|
|
5
|
+
* Lazily creates and caches viem PublicClient instances per chain ID.
|
|
6
|
+
*/
|
|
7
|
+
import { createPublicClient, http } from 'viem';
|
|
8
|
+
import { RPC_ENDPOINTS, CHAIN_NAMES } from './addresses.js';
|
|
9
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
10
|
+
/**
|
|
11
|
+
* Extract the chain ID from an `eip155:{chainId}:{address}` agent registry string.
|
|
12
|
+
* Returns `null` if the format is invalid.
|
|
13
|
+
*/
|
|
14
|
+
export function parseChainId(agentRegistry) {
|
|
15
|
+
const parts = agentRegistry.split(':');
|
|
16
|
+
if (parts.length !== 3 || parts[0] !== 'eip155')
|
|
17
|
+
return null;
|
|
18
|
+
const id = parseInt(parts[1], 10);
|
|
19
|
+
return Number.isFinite(id) ? id : null;
|
|
20
|
+
}
|
|
21
|
+
// ─── Factory ─────────────────────────────────────────────────────────
|
|
22
|
+
/**
|
|
23
|
+
* Create a ClientResolver that lazily creates and caches PublicClient instances.
|
|
24
|
+
*
|
|
25
|
+
* RPC resolution order:
|
|
26
|
+
* 1. Explicit `rpcOverrides` map
|
|
27
|
+
* 2. Environment variable `RPC_URL_{chainId}` (e.g. `RPC_URL_42161`)
|
|
28
|
+
* 3. Built-in `RPC_ENDPOINTS` from addresses.ts
|
|
29
|
+
* 4. Throw with a helpful error listing supported chains
|
|
30
|
+
*/
|
|
31
|
+
export function createClientResolver(options) {
|
|
32
|
+
const cache = new Map();
|
|
33
|
+
const overrides = options?.rpcOverrides ?? {};
|
|
34
|
+
const allowed = options?.allowedChainIds
|
|
35
|
+
? new Set(options.allowedChainIds)
|
|
36
|
+
: null;
|
|
37
|
+
function resolveRpcUrl(chainId) {
|
|
38
|
+
// 1. Explicit override
|
|
39
|
+
if (overrides[chainId])
|
|
40
|
+
return overrides[chainId];
|
|
41
|
+
// 2. Environment variable
|
|
42
|
+
const envKey = `RPC_URL_${chainId}`;
|
|
43
|
+
const envVal = typeof process !== 'undefined' ? process.env?.[envKey] : undefined;
|
|
44
|
+
if (envVal)
|
|
45
|
+
return envVal;
|
|
46
|
+
// 3. Built-in defaults
|
|
47
|
+
if (RPC_ENDPOINTS[chainId])
|
|
48
|
+
return RPC_ENDPOINTS[chainId];
|
|
49
|
+
// 4. Not found
|
|
50
|
+
const supported = getSupportedChainIds();
|
|
51
|
+
const names = supported.map((id) => `${id} (${CHAIN_NAMES[id] || 'unknown'})`).join(', ');
|
|
52
|
+
throw new Error(`No RPC endpoint for chain ${chainId}. Supported chains: ${names}. ` +
|
|
53
|
+
`Set RPC_URL_${chainId} or pass rpcOverrides to createClientResolver().`);
|
|
54
|
+
}
|
|
55
|
+
function getSupportedChainIds() {
|
|
56
|
+
const ids = new Set([
|
|
57
|
+
...Object.keys(overrides).map(Number),
|
|
58
|
+
...Object.keys(RPC_ENDPOINTS).map(Number),
|
|
59
|
+
]);
|
|
60
|
+
if (allowed) {
|
|
61
|
+
return [...ids].filter((id) => allowed.has(id));
|
|
62
|
+
}
|
|
63
|
+
return [...ids];
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
getClient(chainId) {
|
|
67
|
+
if (allowed && !allowed.has(chainId)) {
|
|
68
|
+
const names = [...allowed].map((id) => `${id} (${CHAIN_NAMES[id] || 'unknown'})`).join(', ');
|
|
69
|
+
throw new Error(`Chain ${chainId} is not in the allowed list. Allowed: ${names}`);
|
|
70
|
+
}
|
|
71
|
+
let client = cache.get(chainId);
|
|
72
|
+
if (!client) {
|
|
73
|
+
const rpcUrl = resolveRpcUrl(chainId);
|
|
74
|
+
client = createPublicClient({ transport: http(rpcUrl) });
|
|
75
|
+
cache.set(chainId, client);
|
|
76
|
+
}
|
|
77
|
+
return client;
|
|
78
|
+
},
|
|
79
|
+
isSupported(chainId) {
|
|
80
|
+
if (allowed && !allowed.has(chainId))
|
|
81
|
+
return false;
|
|
82
|
+
try {
|
|
83
|
+
resolveRpcUrl(chainId);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
supportedChainIds: getSupportedChainIds,
|
|
91
|
+
};
|
|
92
|
+
}
|
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
package/dist/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -67,9 +67,17 @@
|
|
|
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"
|
|
77
|
+
},
|
|
78
|
+
"./client-resolver": {
|
|
79
|
+
"types": "./dist/client-resolver.d.ts",
|
|
80
|
+
"default": "./dist/client-resolver.js"
|
|
73
81
|
}
|
|
74
82
|
},
|
|
75
83
|
"main": "./dist/index.js",
|