aap-agent-server 2.6.0 → 3.0.0

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/index.js CHANGED
@@ -1,41 +1,39 @@
1
1
  /**
2
- * @aap/server v2.5.0
2
+ * @aap/server v3.0.0
3
3
  *
4
- * Server-side utilities for Agent Attestation Protocol.
5
- * The Reverse Turing Test - CAPTCHAs block bots, AAP blocks humans.
4
+ * WebSocket-only Agent Attestation Protocol.
5
+ * Sequential challenges - no preview, no mercy.
6
6
  */
7
7
 
8
- export * from './middleware.js';
9
- export * from './challenges.js';
10
- export * from './ratelimit.js';
11
- export * from './whitelist.js';
12
- export * from './persistence.js';
13
- export * from './errors.js';
8
+ // Core WebSocket server
9
+ export { createAAPWebSocket } from './websocket.js';
10
+
11
+ // Challenge generation (internal use)
12
+ export {
13
+ generate,
14
+ generateBatch,
15
+ validateBatch,
16
+ getTypes,
17
+ BATCH_SIZE,
18
+ CHALLENGE_TYPES
19
+ } from './challenges.js';
20
+
21
+ // Optional utilities
22
+ export { createWhitelist, createKeyRotation } from './whitelist.js';
23
+ export { createStore, createMemoryStore, createFileStore, createRedisStore } from './persistence.js';
14
24
  export * as logger from './logger.js';
15
25
 
16
- import { aapMiddleware, createRouter } from './middleware.js';
17
- import challenges from './challenges.js';
18
- import { createRateLimiter, createFailureLimiter } from './ratelimit.js';
19
- import { createWhitelist, createKeyRotation } from './whitelist.js';
20
- import { createStore, createMemoryStore, createFileStore, createRedisStore } from './persistence.js';
26
+ // Constants
27
+ export const PROTOCOL_VERSION = '3.0.0';
28
+ export const TIME_PER_CHALLENGE_MS = 1200;
29
+ export const CHALLENGE_COUNT = 7;
30
+ export const CONNECTION_TIMEOUT_MS = 15000;
21
31
 
22
- export { challenges };
32
+ import { createAAPWebSocket } from './websocket.js';
23
33
 
24
34
  export default {
25
- // Core
26
- aapMiddleware,
27
- createRouter,
28
- challenges,
29
-
30
- // Security
31
- createRateLimiter,
32
- createFailureLimiter,
33
- createWhitelist,
34
- createKeyRotation,
35
-
36
- // Persistence
37
- createStore,
38
- createMemoryStore,
39
- createFileStore,
40
- createRedisStore
35
+ createAAPWebSocket,
36
+ PROTOCOL_VERSION,
37
+ TIME_PER_CHALLENGE_MS,
38
+ CHALLENGE_COUNT
41
39
  };
package/package.json CHANGED
@@ -1,17 +1,16 @@
1
1
  {
2
2
  "name": "aap-agent-server",
3
- "version": "2.6.0",
4
- "description": "Server middleware for Agent Attestation Protocol - verify AI agents",
3
+ "version": "3.0.0",
4
+ "description": "WebSocket server for Agent Attestation Protocol - verify AI agents",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
7
  "type": "module",
8
8
  "exports": {
9
9
  ".": "./index.js",
10
- "./middleware": "./middleware.js",
11
- "./challenges": "./challenges.js"
10
+ "./websocket": "./websocket.js"
12
11
  },
13
12
  "files": ["*.js", "README.md"],
14
- "keywords": ["aap", "agent", "attestation", "verification", "middleware", "express", "ai"],
13
+ "keywords": ["aap", "agent", "attestation", "verification", "websocket", "ai"],
15
14
  "author": "ira-hash",
16
15
  "license": "MIT",
17
16
  "repository": {
@@ -21,15 +20,7 @@
21
20
  },
22
21
  "homepage": "https://github.com/ira-hash/agent-attestation-protocol#readme",
23
22
  "dependencies": {
24
- "aap-agent-core": "^2.6.0"
25
- },
26
- "peerDependencies": {
27
- "express": "^4.18.0 || ^5.0.0"
28
- },
29
- "peerDependenciesMeta": {
30
- "express": {
31
- "optional": true
32
- }
23
+ "ws": "^8.16.0"
33
24
  },
34
25
  "engines": {
35
26
  "node": ">=18.0.0"
package/websocket.js ADDED
@@ -0,0 +1,366 @@
1
+ /**
2
+ * AAP WebSocket Server v3.0
3
+ *
4
+ * Sequential challenge delivery over persistent connection.
5
+ * No preview. Server controls pacing. Humans cannot pass.
6
+ */
7
+
8
+ import { WebSocketServer } from 'ws';
9
+ import { randomBytes, createHash, createVerify } from 'node:crypto';
10
+
11
+ // ============== CONSTANTS ==============
12
+ export const PROTOCOL_VERSION = '3.0.0';
13
+ export const CHALLENGE_COUNT = 7;
14
+ export const TIME_PER_CHALLENGE_MS = 1200;
15
+ export const CONNECTION_TIMEOUT_MS = 15000;
16
+
17
+ // ============== CHALLENGE GENERATORS ==============
18
+ const GENERATORS = {
19
+ math: (salt, seed) => {
20
+ const a = 10 + (seed % 90);
21
+ const b = 10 + ((seed * 7) % 90);
22
+ const ops = ['+', '-', '*'];
23
+ const op = ops[seed % 3];
24
+ const answer = op === '+' ? a + b : op === '-' ? a - b : a * b;
25
+ return {
26
+ q: `[REQ-${salt}] What is ${a} ${op} ${b}?\nFormat: {"salt":"${salt}","result":number}`,
27
+ v: (r) => r?.salt === salt && r?.result === answer
28
+ };
29
+ },
30
+
31
+ logic: (salt, seed) => {
32
+ const x = 10 + (seed % 50);
33
+ const y = 10 + ((seed * 3) % 50);
34
+ const answer = x > y ? 'GREATER' : x < y ? 'LESS' : 'EQUAL';
35
+ return {
36
+ q: `[REQ-${salt}] X=${x}, Y=${y}. Answer "GREATER" if X>Y, "LESS" if X<Y, "EQUAL" if X=Y.\nFormat: {"salt":"${salt}","answer":"..."}`,
37
+ v: (r) => r?.salt === salt && r?.answer === answer
38
+ };
39
+ },
40
+
41
+ count: (salt, seed) => {
42
+ const all = ['cat','dog','apple','bird','car','fish','tree','book','lion','grape'];
43
+ const animals = ['cat','dog','bird','fish','lion'];
44
+ const n = 4 + (seed % 5);
45
+ const items = [];
46
+ for (let i = 0; i < n; i++) {
47
+ items.push(all[(seed + i * 3) % all.length]);
48
+ }
49
+ const count = items.filter(i => animals.includes(i)).length;
50
+ return {
51
+ q: `[REQ-${salt}] Count animals: ${items.join(', ')}\nFormat: {"salt":"${salt}","count":number}`,
52
+ v: (r) => r?.salt === salt && r?.count === count
53
+ };
54
+ },
55
+
56
+ pattern: (salt, seed) => {
57
+ const start = 2 + (seed % 10);
58
+ const step = 2 + (seed % 6);
59
+ const seq = [start, start+step, start+step*2, start+step*3];
60
+ const next = [start+step*4, start+step*5];
61
+ return {
62
+ q: `[REQ-${salt}] Next 2 numbers: [${seq.join(', ')}, ?, ?]\nFormat: {"salt":"${salt}","next":[n1,n2]}`,
63
+ v: (r) => r?.salt === salt && Array.isArray(r?.next) && r.next[0] === next[0] && r.next[1] === next[1]
64
+ };
65
+ },
66
+
67
+ reverse: (salt, seed) => {
68
+ const words = ['hello','world','agent','robot','claw','alpha','delta'];
69
+ const word = words[seed % words.length];
70
+ const rev = word.split('').reverse().join('');
71
+ return {
72
+ q: `[REQ-${salt}] Reverse the string: "${word}"\nFormat: {"salt":"${salt}","result":"..."}`,
73
+ v: (r) => r?.salt === salt && r?.result === rev
74
+ };
75
+ },
76
+
77
+ extract: (salt, seed) => {
78
+ const colors = ['red','blue','green','yellow','purple','orange'];
79
+ const color = colors[seed % colors.length];
80
+ const nouns = ['car','robot','bird','house'];
81
+ const noun = nouns[seed % nouns.length];
82
+ return {
83
+ q: `[REQ-${salt}] Extract the color: "The ${color} ${noun} moved quickly"\nFormat: {"salt":"${salt}","color":"..."}`,
84
+ v: (r) => r?.salt === salt && r?.color === color
85
+ };
86
+ },
87
+
88
+ longest: (salt, seed) => {
89
+ const sets = [
90
+ ['cat', 'elephant', 'dog', 'ant'],
91
+ ['bee', 'hippopotamus', 'fox', 'rat'],
92
+ ['owl', 'crocodile', 'bat', 'fly']
93
+ ];
94
+ const words = sets[seed % sets.length];
95
+ const longest = words.reduce((a, b) => a.length >= b.length ? a : b);
96
+ return {
97
+ q: `[REQ-${salt}] Find longest word: ${words.join(', ')}\nFormat: {"salt":"${salt}","answer":"..."}`,
98
+ v: (r) => r?.salt === salt && r?.answer === longest
99
+ };
100
+ }
101
+ };
102
+
103
+ const TYPES = Object.keys(GENERATORS);
104
+
105
+ function generateChallenge(nonce, index) {
106
+ const type = TYPES[index % TYPES.length];
107
+ const salt = createHash('sha256').update(nonce + index).digest('hex').slice(0, 6).toUpperCase();
108
+ const seed = parseInt(nonce.slice(index * 2, index * 2 + 8), 16) || (index * 17);
109
+ const { q, v } = GENERATORS[type](salt, seed);
110
+ return { type, challenge: q, validate: v };
111
+ }
112
+
113
+ // ============== WEBSOCKET SERVER ==============
114
+
115
+ /**
116
+ * Create AAP WebSocket verification server
117
+ * @param {Object} options
118
+ * @param {number} [options.port] - Port for standalone server
119
+ * @param {Object} [options.server] - Existing HTTP server to attach to
120
+ * @param {string} [options.path='/aap'] - WebSocket path
121
+ * @param {number} [options.challengeCount=7] - Number of challenges
122
+ * @param {number} [options.timePerChallengeMs=1200] - Time limit per challenge
123
+ * @param {Function} [options.onVerified] - Callback on successful verification
124
+ * @param {Function} [options.onFailed] - Callback on failed verification
125
+ * @returns {Object} { wss, sessions, close }
126
+ */
127
+ export function createAAPWebSocket(options = {}) {
128
+ const {
129
+ port,
130
+ server,
131
+ path = '/aap',
132
+ challengeCount = CHALLENGE_COUNT,
133
+ timePerChallengeMs = TIME_PER_CHALLENGE_MS,
134
+ connectionTimeoutMs = CONNECTION_TIMEOUT_MS,
135
+ onVerified,
136
+ onFailed
137
+ } = options;
138
+
139
+ const wssOptions = server ? { server, path } : { port };
140
+ const wss = new WebSocketServer(wssOptions);
141
+ const sessions = new Map();
142
+ const verifiedTokens = new Map();
143
+
144
+ wss.on('connection', (ws) => {
145
+ const sessionId = randomBytes(16).toString('hex');
146
+ const nonce = randomBytes(16).toString('hex');
147
+ const startTime = Date.now();
148
+
149
+ const session = {
150
+ id: sessionId,
151
+ ws,
152
+ nonce,
153
+ startTime,
154
+ current: 0,
155
+ challenges: [],
156
+ answers: [],
157
+ timings: [],
158
+ publicId: null,
159
+ challengeStart: null,
160
+ timer: null,
161
+ connTimer: null
162
+ };
163
+
164
+ sessions.set(sessionId, session);
165
+
166
+ // Generate challenges
167
+ for (let i = 0; i < challengeCount; i++) {
168
+ session.challenges.push(generateChallenge(nonce, i));
169
+ }
170
+
171
+ // Connection timeout
172
+ session.connTimer = setTimeout(() => {
173
+ send(ws, { type: 'error', code: 'TIMEOUT', message: 'Connection timeout' });
174
+ ws.close();
175
+ }, connectionTimeoutMs);
176
+
177
+ // Send handshake
178
+ send(ws, {
179
+ type: 'handshake',
180
+ sessionId,
181
+ protocol: 'AAP',
182
+ version: PROTOCOL_VERSION,
183
+ challengeCount,
184
+ timePerChallengeMs,
185
+ message: 'Send {"type":"ready"} to begin verification.'
186
+ });
187
+
188
+ ws.on('message', (data) => {
189
+ let msg;
190
+ try {
191
+ msg = JSON.parse(data.toString());
192
+ } catch {
193
+ send(ws, { type: 'error', code: 'INVALID_JSON', message: 'Invalid JSON' });
194
+ return;
195
+ }
196
+
197
+ handleMessage(session, msg, {
198
+ challengeCount,
199
+ timePerChallengeMs,
200
+ onVerified,
201
+ onFailed,
202
+ verifiedTokens
203
+ });
204
+ });
205
+
206
+ ws.on('close', () => {
207
+ cleanup(session);
208
+ sessions.delete(sessionId);
209
+ });
210
+
211
+ ws.on('error', () => {
212
+ cleanup(session);
213
+ sessions.delete(sessionId);
214
+ });
215
+ });
216
+
217
+ return {
218
+ wss,
219
+ sessions,
220
+ verifiedTokens,
221
+ close: () => wss.close(),
222
+
223
+ // Helper to check if token is verified
224
+ isVerified: (token) => {
225
+ const session = verifiedTokens.get(token);
226
+ return session && Date.now() < session.expiresAt;
227
+ },
228
+
229
+ // Get verified session info
230
+ getSession: (token) => verifiedTokens.get(token)
231
+ };
232
+ }
233
+
234
+ function send(ws, data) {
235
+ if (ws.readyState === 1) {
236
+ ws.send(JSON.stringify(data));
237
+ }
238
+ }
239
+
240
+ function cleanup(session) {
241
+ clearTimeout(session.timer);
242
+ clearTimeout(session.connTimer);
243
+ }
244
+
245
+ function handleMessage(session, msg, options) {
246
+ const { ws, current, challenges, answers, timings } = session;
247
+ const { challengeCount, timePerChallengeMs, onVerified, onFailed, verifiedTokens } = options;
248
+
249
+ switch (msg.type) {
250
+ case 'ready':
251
+ if (current !== 0) {
252
+ send(ws, { type: 'error', code: 'ALREADY_STARTED', message: 'Already started' });
253
+ return;
254
+ }
255
+ session.publicId = msg.publicId || 'anon-' + randomBytes(4).toString('hex');
256
+ sendNextChallenge(session, timePerChallengeMs);
257
+ break;
258
+
259
+ case 'answer':
260
+ if (session.challengeStart === null) {
261
+ send(ws, { type: 'error', code: 'NO_CHALLENGE', message: 'No active challenge' });
262
+ return;
263
+ }
264
+
265
+ clearTimeout(session.timer);
266
+ const elapsed = Date.now() - session.challengeStart;
267
+
268
+ // Too slow?
269
+ if (elapsed > timePerChallengeMs) {
270
+ finishSession(session, false, `Too slow on challenge #${current}: ${elapsed}ms > ${timePerChallengeMs}ms`, options);
271
+ return;
272
+ }
273
+
274
+ // Record answer
275
+ answers.push(msg.answer);
276
+ timings.push(elapsed);
277
+
278
+ // Validate
279
+ const valid = challenges[current].validate(msg.answer);
280
+ send(ws, { type: 'ack', id: current, valid, responseMs: elapsed });
281
+
282
+ session.current++;
283
+
284
+ // More challenges?
285
+ if (session.current < challengeCount) {
286
+ setTimeout(() => sendNextChallenge(session, timePerChallengeMs), 50);
287
+ } else {
288
+ // Calculate final result
289
+ const passed = answers.filter((a, i) => challenges[i].validate(a)).length;
290
+ const success = passed === challengeCount;
291
+ finishSession(session, success, success ? 'All challenges passed' : `Failed: ${passed}/${challengeCount}`, options, passed);
292
+ }
293
+ break;
294
+
295
+ default:
296
+ send(ws, { type: 'error', code: 'UNKNOWN_TYPE', message: 'Unknown message type' });
297
+ }
298
+ }
299
+
300
+ function sendNextChallenge(session, timePerChallengeMs) {
301
+ const { ws, current, challenges } = session;
302
+ const challenge = challenges[current];
303
+
304
+ session.challengeStart = Date.now();
305
+
306
+ send(ws, {
307
+ type: 'challenge',
308
+ id: current,
309
+ total: challenges.length,
310
+ challenge: challenge.challenge,
311
+ timeLimit: timePerChallengeMs
312
+ });
313
+
314
+ // Timeout for this challenge
315
+ session.timer = setTimeout(() => {
316
+ send(ws, { type: 'timeout', id: current, message: 'Time expired' });
317
+ finishSession(session, false, `Timeout on challenge #${current}`, {});
318
+ }, timePerChallengeMs + 100);
319
+ }
320
+
321
+ function finishSession(session, success, message, options, passed) {
322
+ const { ws, nonce, publicId, startTime, timings } = session;
323
+ const { onVerified, onFailed, verifiedTokens } = options;
324
+
325
+ cleanup(session);
326
+
327
+ const totalTime = Date.now() - startTime;
328
+ const result = {
329
+ type: 'result',
330
+ verified: success,
331
+ message,
332
+ nonce,
333
+ publicId,
334
+ passed,
335
+ total: session.challenges.length,
336
+ timings,
337
+ totalTimeMs: totalTime,
338
+ avgResponseMs: timings.length ? Math.round(timings.reduce((a, b) => a + b, 0) / timings.length) : null
339
+ };
340
+
341
+ if (success) {
342
+ result.role = 'AI_AGENT';
343
+ result.sessionToken = randomBytes(32).toString('hex');
344
+
345
+ // Store verified session
346
+ if (verifiedTokens) {
347
+ verifiedTokens.set(result.sessionToken, {
348
+ publicId,
349
+ nonce,
350
+ verifiedAt: Date.now(),
351
+ expiresAt: Date.now() + 3600000, // 1 hour
352
+ totalTimeMs: totalTime,
353
+ avgResponseMs: result.avgResponseMs
354
+ });
355
+ }
356
+
357
+ if (onVerified) onVerified(result, session);
358
+ } else {
359
+ if (onFailed) onFailed(result, session);
360
+ }
361
+
362
+ send(ws, result);
363
+ setTimeout(() => ws.close(), 300);
364
+ }
365
+
366
+ export default { createAAPWebSocket, PROTOCOL_VERSION, CHALLENGE_COUNT, TIME_PER_CHALLENGE_MS };
package/errors.js DELETED
@@ -1,134 +0,0 @@
1
- /**
2
- * AAP Server - Error Definitions
3
- *
4
- * Consistent, client-friendly error messages
5
- */
6
-
7
- export const ErrorCodes = {
8
- // Challenge errors
9
- CHALLENGE_NOT_FOUND: 'CHALLENGE_NOT_FOUND',
10
- CHALLENGE_EXPIRED: 'CHALLENGE_EXPIRED',
11
- CHALLENGE_ALREADY_USED: 'CHALLENGE_ALREADY_USED',
12
-
13
- // Solution errors
14
- MISSING_SOLUTIONS: 'MISSING_SOLUTIONS',
15
- INVALID_SOLUTIONS_COUNT: 'INVALID_SOLUTIONS_COUNT',
16
- SOLUTION_VALIDATION_FAILED: 'SOLUTION_VALIDATION_FAILED',
17
-
18
- // Timing errors
19
- RESPONSE_TOO_SLOW: 'RESPONSE_TOO_SLOW',
20
-
21
- // Signature errors
22
- INVALID_SIGNATURE: 'INVALID_SIGNATURE',
23
- MISSING_SIGNATURE: 'MISSING_SIGNATURE',
24
- INVALID_PUBLIC_KEY: 'INVALID_PUBLIC_KEY',
25
-
26
- // General errors
27
- INVALID_REQUEST: 'INVALID_REQUEST',
28
- INTERNAL_ERROR: 'INTERNAL_ERROR',
29
- RATE_LIMITED: 'RATE_LIMITED'
30
- };
31
-
32
- export const ErrorMessages = {
33
- [ErrorCodes.CHALLENGE_NOT_FOUND]: {
34
- message: 'Challenge not found',
35
- hint: 'Request a new challenge via POST /challenge',
36
- status: 400
37
- },
38
- [ErrorCodes.CHALLENGE_EXPIRED]: {
39
- message: 'Challenge has expired',
40
- hint: 'Challenges expire after 60 seconds. Request a new one.',
41
- status: 400
42
- },
43
- [ErrorCodes.CHALLENGE_ALREADY_USED]: {
44
- message: 'Challenge already used',
45
- hint: 'Each challenge can only be used once. Request a new one.',
46
- status: 400
47
- },
48
- [ErrorCodes.MISSING_SOLUTIONS]: {
49
- message: 'Missing solutions array',
50
- hint: 'Include "solutions" array in request body',
51
- status: 400
52
- },
53
- [ErrorCodes.INVALID_SOLUTIONS_COUNT]: {
54
- message: 'Invalid number of solutions',
55
- hint: 'Provide exactly 3 solutions for batch challenges',
56
- status: 400
57
- },
58
- [ErrorCodes.SOLUTION_VALIDATION_FAILED]: {
59
- message: 'Solution validation failed (Proof of Intelligence)',
60
- hint: 'One or more solutions are incorrect. Ensure your LLM correctly solves each challenge.',
61
- status: 400
62
- },
63
- [ErrorCodes.RESPONSE_TOO_SLOW]: {
64
- message: 'Response too slow (Proof of Liveness failed)',
65
- hint: 'Response must arrive within 12 seconds for batch challenges',
66
- status: 400
67
- },
68
- [ErrorCodes.INVALID_SIGNATURE]: {
69
- message: 'Invalid signature (Proof of Identity failed)',
70
- hint: 'Ensure you sign the correct data with your private key',
71
- status: 400
72
- },
73
- [ErrorCodes.MISSING_SIGNATURE]: {
74
- message: 'Missing signature',
75
- hint: 'Include "signature" field with Base64-encoded ECDSA signature',
76
- status: 400
77
- },
78
- [ErrorCodes.INVALID_PUBLIC_KEY]: {
79
- message: 'Invalid public key',
80
- hint: 'Public key must be PEM-encoded secp256k1 key',
81
- status: 400
82
- },
83
- [ErrorCodes.INVALID_REQUEST]: {
84
- message: 'Invalid request format',
85
- hint: 'Check the API documentation for required fields',
86
- status: 400
87
- },
88
- [ErrorCodes.INTERNAL_ERROR]: {
89
- message: 'Internal server error',
90
- hint: 'Please try again later',
91
- status: 500
92
- },
93
- [ErrorCodes.RATE_LIMITED]: {
94
- message: 'Too many requests',
95
- hint: 'Please wait before making more requests',
96
- status: 429
97
- }
98
- };
99
-
100
- /**
101
- * Create an error response
102
- * @param {string} code - Error code from ErrorCodes
103
- * @param {Object} [extra] - Additional fields to include
104
- * @returns {Object} Error response object
105
- */
106
- export function createError(code, extra = {}) {
107
- const errorDef = ErrorMessages[code] || ErrorMessages[ErrorCodes.INTERNAL_ERROR];
108
-
109
- return {
110
- verified: false,
111
- error: errorDef.message,
112
- code,
113
- hint: errorDef.hint,
114
- ...extra
115
- };
116
- }
117
-
118
- /**
119
- * Create error response with HTTP status
120
- * @param {string} code - Error code
121
- * @param {Object} res - Express response object
122
- * @param {Object} [extra] - Additional fields
123
- */
124
- export function sendError(code, res, extra = {}) {
125
- const errorDef = ErrorMessages[code] || ErrorMessages[ErrorCodes.INTERNAL_ERROR];
126
- res.status(errorDef.status).json(createError(code, extra));
127
- }
128
-
129
- export default {
130
- ErrorCodes,
131
- ErrorMessages,
132
- createError,
133
- sendError
134
- };
package/middleware.js DELETED
@@ -1,420 +0,0 @@
1
- /**
2
- * @aap/server - Express Middleware
3
- *
4
- * Drop-in middleware for adding AAP verification to Express apps.
5
- */
6
-
7
- import { verify, generateNonce, createProofData } from 'aap-agent-core';
8
- import {
9
- generate as generateChallenge,
10
- generateBatch,
11
- validateBatch,
12
- getTypes,
13
- validate as validateSolution,
14
- BATCH_SIZE,
15
- MAX_RESPONSE_TIME_MS,
16
- CHALLENGE_EXPIRY_MS
17
- } from './challenges.js';
18
- import * as logger from './logger.js';
19
- import { ErrorCodes, sendError } from './errors.js';
20
-
21
- /**
22
- * Create AAP verification middleware/router
23
- *
24
- * @param {Object} [options]
25
- * @param {number} [options.challengeExpiryMs=60000] - Challenge expiration time
26
- * @param {number} [options.maxResponseTimeMs=8000] - Max response time for batch
27
- * @param {number} [options.batchSize=5] - Number of challenges per batch
28
- * @param {number} [options.minPassCount] - Minimum challenges to pass (default: all)
29
- * @param {Function} [options.onVerified] - Callback when agent is verified
30
- * @param {Function} [options.onFailed] - Callback when verification fails
31
- * @returns {Function} Express router
32
- */
33
- export function aapMiddleware(options = {}) {
34
- const {
35
- challengeExpiryMs = CHALLENGE_EXPIRY_MS,
36
- maxResponseTimeMs = MAX_RESPONSE_TIME_MS,
37
- batchSize = BATCH_SIZE,
38
- minPassCount = null, // null = all must pass
39
- onVerified,
40
- onFailed
41
- } = options;
42
-
43
- // In-memory challenge store with size limit (DoS protection)
44
- const MAX_CHALLENGES = 10000;
45
- const challenges = new Map();
46
-
47
- // Cleanup expired challenges periodically
48
- const cleanup = () => {
49
- const now = Date.now();
50
- for (const [nonce, challenge] of challenges.entries()) {
51
- if (now > challenge.expiresAt) {
52
- challenges.delete(nonce);
53
- }
54
- }
55
-
56
- // Emergency cleanup if still too many (keep newest)
57
- if (challenges.size > MAX_CHALLENGES) {
58
- const entries = [...challenges.entries()]
59
- .sort((a, b) => b[1].timestamp - a[1].timestamp)
60
- .slice(0, MAX_CHALLENGES / 2);
61
- challenges.clear();
62
- entries.forEach(([k, v]) => challenges.set(k, v));
63
- }
64
- };
65
-
66
- // Return a function that creates routes
67
- return (router) => {
68
- /**
69
- * GET /health - Health check
70
- */
71
- router.get('/health', (req, res) => {
72
- res.json({
73
- status: 'ok',
74
- protocol: 'AAP',
75
- version: '2.0.0',
76
- mode: 'batch',
77
- batchSize,
78
- maxResponseTimeMs,
79
- challengeTypes: getTypes(),
80
- activeChallenges: challenges.size
81
- });
82
- });
83
-
84
- /**
85
- * POST /challenge - Request a batch of challenges
86
- */
87
- router.post('/challenge', (req, res) => {
88
- cleanup();
89
-
90
- const nonce = generateNonce();
91
- const timestamp = Date.now();
92
- const { challenges: batchChallenges, validators, expected } = generateBatch(nonce, batchSize);
93
-
94
- const challengeData = {
95
- nonce,
96
- challenges: batchChallenges,
97
- batchSize,
98
- timestamp,
99
- expiresAt: timestamp + challengeExpiryMs,
100
- maxResponseTimeMs
101
- };
102
-
103
- // Store with validators (not sent to client)
104
- challenges.set(nonce, {
105
- ...challengeData,
106
- validators,
107
- expected // For debugging
108
- });
109
-
110
- // Send without validators
111
- res.json({
112
- nonce,
113
- challenges: batchChallenges,
114
- batchSize,
115
- timestamp,
116
- expiresAt: challengeData.expiresAt,
117
- maxResponseTimeMs
118
- });
119
- });
120
-
121
- /**
122
- * POST /verify - Verify agent's batch solutions
123
- */
124
- router.post('/verify', (req, res) => {
125
- const {
126
- solutions,
127
- signature,
128
- publicKey,
129
- publicId,
130
- nonce,
131
- timestamp,
132
- responseTimeMs
133
- } = req.body;
134
-
135
- const checks = {
136
- inputValid: false,
137
- challengeExists: false,
138
- notExpired: false,
139
- solutionsExist: false,
140
- solutionsValid: false,
141
- responseTimeValid: false,
142
- signatureValid: false
143
- };
144
-
145
- try {
146
- // Check 0: Input validation (security)
147
- if (!nonce || typeof nonce !== 'string' || nonce.length !== 32) {
148
- return res.status(400).json({ verified: false, error: 'Invalid nonce format', checks });
149
- }
150
- if (!publicId || typeof publicId !== 'string' || publicId.length !== 20) {
151
- return res.status(400).json({ verified: false, error: 'Invalid publicId format', checks });
152
- }
153
- if (!signature || typeof signature !== 'string' || signature.length < 50) {
154
- return res.status(400).json({ verified: false, error: 'Invalid signature format', checks });
155
- }
156
- if (!publicKey || typeof publicKey !== 'string' || !publicKey.includes('BEGIN PUBLIC KEY')) {
157
- return res.status(400).json({ verified: false, error: 'Invalid publicKey format', checks });
158
- }
159
- if (!timestamp || typeof timestamp !== 'number') {
160
- return res.status(400).json({ verified: false, error: 'Invalid timestamp', checks });
161
- }
162
- if (!responseTimeMs || typeof responseTimeMs !== 'number' || responseTimeMs < 0) {
163
- return res.status(400).json({ verified: false, error: 'Invalid responseTimeMs', checks });
164
- }
165
- checks.inputValid = true;
166
-
167
- // Check 1: Challenge exists (check BEFORE delete for race condition fix)
168
- const challenge = challenges.get(nonce);
169
- if (!challenge) {
170
- if (onFailed) onFailed({ error: 'Challenge not found', checks }, req);
171
- return res.status(400).json({
172
- verified: false,
173
- error: 'Challenge not found or already used',
174
- checks
175
- });
176
- }
177
- checks.challengeExists = true;
178
-
179
- // Check 2: Not expired (check BEFORE delete - race condition fix)
180
- if (Date.now() > challenge.expiresAt) {
181
- challenges.delete(nonce); // Clean up expired
182
- if (onFailed) onFailed({ error: 'Challenge expired', checks }, req);
183
- return res.status(400).json({
184
- verified: false,
185
- error: 'Challenge expired',
186
- checks
187
- });
188
- }
189
- checks.notExpired = true;
190
-
191
- // Remove challenge (one-time use) - only after expiry check
192
- const { validators, batchSize: size } = challenge;
193
- challenges.delete(nonce);
194
-
195
- // Check 3: Solutions exist
196
- if (!solutions || !Array.isArray(solutions) || solutions.length !== size) {
197
- if (onFailed) onFailed({ error: 'Invalid solutions array', checks }, req);
198
- return res.status(400).json({
199
- verified: false,
200
- error: `Expected ${size} solutions, got ${solutions?.length || 0}`,
201
- checks
202
- });
203
- }
204
- checks.solutionsExist = true;
205
-
206
- // Check 4: Validate all solutions (Proof of Intelligence)
207
- const batchResult = validateBatch(validators, solutions);
208
- const requiredPass = minPassCount || size;
209
-
210
- if (batchResult.passed < requiredPass) {
211
- if (onFailed) onFailed({ error: 'Solutions validation failed', checks, batchResult }, req);
212
- return res.status(400).json({
213
- verified: false,
214
- error: `Proof of Intelligence failed: ${batchResult.passed}/${size} correct (need ${requiredPass})`,
215
- checks,
216
- batchResult
217
- });
218
- }
219
- checks.solutionsValid = true;
220
-
221
- // Check 5: Response time (Proof of Liveness) - SERVER-SIDE validation
222
- const serverResponseTime = Date.now() - challenge.timestamp;
223
- const effectiveResponseTime = Math.max(responseTimeMs, serverResponseTime);
224
-
225
- if (effectiveResponseTime > maxResponseTimeMs) {
226
- if (onFailed) onFailed({ error: 'Response too slow', checks }, req);
227
- return res.status(400).json({
228
- verified: false,
229
- error: `Response too slow: ${effectiveResponseTime}ms > ${maxResponseTimeMs}ms (Proof of Liveness failed)`,
230
- checks,
231
- timing: { client: responseTimeMs, server: serverResponseTime }
232
- });
233
- }
234
- checks.responseTimeValid = true;
235
-
236
- // Check 6: Signature (Proof of Identity)
237
- // Sign over solutions array
238
- const solutionsString = JSON.stringify(solutions);
239
- const proofData = createProofData({ nonce, solution: solutionsString, publicId, timestamp });
240
- if (!verify(proofData, signature, publicKey)) {
241
- if (onFailed) onFailed({ error: 'Invalid signature', checks }, req);
242
- return res.status(400).json({
243
- verified: false,
244
- error: 'Invalid signature (Proof of Identity failed)',
245
- checks
246
- });
247
- }
248
- checks.signatureValid = true;
249
-
250
- // All checks passed
251
- const result = {
252
- verified: true,
253
- role: 'AI_AGENT',
254
- publicId,
255
- batchResult,
256
- responseTimeMs,
257
- checks
258
- };
259
-
260
- if (onVerified) onVerified(result, req);
261
-
262
- res.json(result);
263
-
264
- } catch (error) {
265
- if (onFailed) onFailed({ error: error.message, checks }, req);
266
- res.status(500).json({
267
- verified: false,
268
- error: `Verification error: ${error.message}`,
269
- checks
270
- });
271
- }
272
- });
273
-
274
- // ============== Legacy single-challenge endpoints ==============
275
-
276
- /**
277
- * POST /challenge/single - Request a single challenge (legacy)
278
- */
279
- router.post('/challenge/single', (req, res) => {
280
- cleanup();
281
-
282
- const nonce = generateNonce();
283
- const timestamp = Date.now();
284
- const { type, challenge_string, validate } = generateChallenge(nonce);
285
-
286
- const challenge = {
287
- challenge_string,
288
- nonce,
289
- type,
290
- difficulty: 1,
291
- timestamp,
292
- expiresAt: timestamp + challengeExpiryMs,
293
- mode: 'single'
294
- };
295
-
296
- challenges.set(nonce, { ...challenge, validate });
297
-
298
- res.json({
299
- challenge_string,
300
- nonce,
301
- type,
302
- difficulty: 1,
303
- timestamp,
304
- expiresAt: challenge.expiresAt,
305
- mode: 'single',
306
- maxResponseTimeMs: 10000 // 10s for single
307
- });
308
- });
309
-
310
- /**
311
- * POST /verify/single - Verify single challenge (legacy)
312
- */
313
- router.post('/verify/single', (req, res) => {
314
- const {
315
- solution,
316
- signature,
317
- publicKey,
318
- publicId,
319
- nonce,
320
- timestamp,
321
- responseTimeMs
322
- } = req.body;
323
-
324
- const checks = {
325
- challengeExists: false,
326
- notExpired: false,
327
- solutionExists: false,
328
- solutionValid: false,
329
- responseTimeValid: false,
330
- signatureValid: false
331
- };
332
-
333
- try {
334
- const challenge = challenges.get(nonce);
335
- if (!challenge || challenge.mode !== 'single') {
336
- return res.status(400).json({
337
- verified: false,
338
- error: 'Single challenge not found',
339
- checks
340
- });
341
- }
342
- checks.challengeExists = true;
343
-
344
- const { validate, type: challengeType } = challenge;
345
- challenges.delete(nonce);
346
-
347
- if (Date.now() > challenge.expiresAt) {
348
- return res.status(400).json({ verified: false, error: 'Challenge expired', checks });
349
- }
350
- checks.notExpired = true;
351
-
352
- if (!solution) {
353
- return res.status(400).json({ verified: false, error: 'Missing solution', checks });
354
- }
355
- checks.solutionExists = true;
356
-
357
- if (!validate(solution)) {
358
- return res.status(400).json({ verified: false, error: 'Invalid solution', checks });
359
- }
360
- checks.solutionValid = true;
361
-
362
- if (responseTimeMs > 10000) {
363
- return res.status(400).json({ verified: false, error: 'Too slow', checks });
364
- }
365
- checks.responseTimeValid = true;
366
-
367
- const proofData = createProofData({ nonce, solution, publicId, timestamp });
368
- if (!verify(proofData, signature, publicKey)) {
369
- return res.status(400).json({ verified: false, error: 'Invalid signature', checks });
370
- }
371
- checks.signatureValid = true;
372
-
373
- res.json({
374
- verified: true,
375
- role: 'AI_AGENT',
376
- publicId,
377
- challengeType,
378
- checks
379
- });
380
-
381
- } catch (error) {
382
- res.status(500).json({ verified: false, error: error.message, checks });
383
- }
384
- });
385
-
386
- return router;
387
- };
388
- }
389
-
390
- /**
391
- * Create a standalone AAP router for Express
392
- *
393
- * @param {Object} [options] - Middleware options
394
- * @returns {Router} Express router
395
- *
396
- * @example
397
- * import express from 'express';
398
- * import { createRouter } from '@aap/server';
399
- *
400
- * const app = express();
401
- * app.use('/aap/v1', createRouter());
402
- */
403
- export async function createRouter(options = {}) {
404
- // Dynamic import for optional express dependency
405
- let express;
406
- try {
407
- express = (await import('express')).default;
408
- } catch {
409
- throw new Error('express is required for createRouter. Install with: npm install express');
410
- }
411
- const router = express.Router();
412
- router.use(express.json());
413
-
414
- const middleware = aapMiddleware(options);
415
- middleware(router);
416
-
417
- return router;
418
- }
419
-
420
- export default { aapMiddleware, createRouter };
package/ratelimit.js DELETED
@@ -1,117 +0,0 @@
1
- /**
2
- * AAP Rate Limiter
3
- *
4
- * Simple in-memory rate limiting (no external dependencies)
5
- */
6
-
7
- /**
8
- * Create a rate limiter
9
- * @param {Object} options
10
- * @param {number} [options.windowMs=60000] - Time window in ms
11
- * @param {number} [options.max=10] - Max requests per window
12
- * @param {string} [options.message] - Error message
13
- * @returns {Function} Express middleware
14
- */
15
- export function createRateLimiter(options = {}) {
16
- const {
17
- windowMs = 60000,
18
- max = 10,
19
- message = 'Too many requests, please try again later'
20
- } = options;
21
-
22
- const requests = new Map();
23
-
24
- // Cleanup old entries periodically
25
- setInterval(() => {
26
- const now = Date.now();
27
- for (const [key, data] of requests.entries()) {
28
- if (now - data.firstRequest > windowMs) {
29
- requests.delete(key);
30
- }
31
- }
32
- }, windowMs);
33
-
34
- return (req, res, next) => {
35
- const key = req.ip || req.connection.remoteAddress || 'unknown';
36
- const now = Date.now();
37
-
38
- let data = requests.get(key);
39
-
40
- if (!data || now - data.firstRequest > windowMs) {
41
- // New window
42
- data = { count: 1, firstRequest: now };
43
- requests.set(key, data);
44
- } else {
45
- data.count++;
46
- }
47
-
48
- // Set headers
49
- const remaining = Math.max(0, max - data.count);
50
- const resetTime = Math.ceil((data.firstRequest + windowMs) / 1000);
51
-
52
- res.setHeader('X-RateLimit-Limit', max);
53
- res.setHeader('X-RateLimit-Remaining', remaining);
54
- res.setHeader('X-RateLimit-Reset', resetTime);
55
-
56
- if (data.count > max) {
57
- res.setHeader('Retry-After', Math.ceil((data.firstRequest + windowMs - now) / 1000));
58
- return res.status(429).json({
59
- error: message,
60
- retryAfter: Math.ceil((data.firstRequest + windowMs - now) / 1000)
61
- });
62
- }
63
-
64
- next();
65
- };
66
- }
67
-
68
- /**
69
- * Create rate limiter for failed attempts
70
- * Stricter limits after failures
71
- */
72
- export function createFailureLimiter(options = {}) {
73
- const {
74
- windowMs = 60000,
75
- maxFailures = 5,
76
- message = 'Too many failed attempts'
77
- } = options;
78
-
79
- const failures = new Map();
80
-
81
- return {
82
- middleware: (req, res, next) => {
83
- const key = req.ip || req.connection.remoteAddress || 'unknown';
84
- const data = failures.get(key);
85
-
86
- if (data && data.count >= maxFailures && Date.now() - data.firstFailure < windowMs) {
87
- return res.status(429).json({
88
- error: message,
89
- retryAfter: Math.ceil((data.firstFailure + windowMs - Date.now()) / 1000)
90
- });
91
- }
92
-
93
- next();
94
- },
95
-
96
- recordFailure: (req) => {
97
- const key = req.ip || req.connection.remoteAddress || 'unknown';
98
- const now = Date.now();
99
- let data = failures.get(key);
100
-
101
- if (!data || now - data.firstFailure > windowMs) {
102
- data = { count: 1, firstFailure: now };
103
- } else {
104
- data.count++;
105
- }
106
-
107
- failures.set(key, data);
108
- },
109
-
110
- clearFailures: (req) => {
111
- const key = req.ip || req.connection.remoteAddress || 'unknown';
112
- failures.delete(key);
113
- }
114
- };
115
- }
116
-
117
- export default { createRateLimiter, createFailureLimiter };