aap-agent-server 2.6.0 → 2.7.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.
Files changed (3) hide show
  1. package/index.js +6 -1
  2. package/package.json +3 -2
  3. package/websocket.js +282 -0
package/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @aap/server v2.5.0
2
+ * @aap/server v2.7.0
3
3
  *
4
4
  * Server-side utilities for Agent Attestation Protocol.
5
5
  * The Reverse Turing Test - CAPTCHAs block bots, AAP blocks humans.
@@ -11,6 +11,7 @@ export * from './ratelimit.js';
11
11
  export * from './whitelist.js';
12
12
  export * from './persistence.js';
13
13
  export * from './errors.js';
14
+ export * from './websocket.js';
14
15
  export * as logger from './logger.js';
15
16
 
16
17
  import { aapMiddleware, createRouter } from './middleware.js';
@@ -18,6 +19,7 @@ import challenges from './challenges.js';
18
19
  import { createRateLimiter, createFailureLimiter } from './ratelimit.js';
19
20
  import { createWhitelist, createKeyRotation } from './whitelist.js';
20
21
  import { createStore, createMemoryStore, createFileStore, createRedisStore } from './persistence.js';
22
+ import { createAAPWebSocket } from './websocket.js';
21
23
 
22
24
  export { challenges };
23
25
 
@@ -27,6 +29,9 @@ export default {
27
29
  createRouter,
28
30
  challenges,
29
31
 
32
+ // WebSocket (v2.7+)
33
+ createAAPWebSocket,
34
+
30
35
  // Security
31
36
  createRateLimiter,
32
37
  createFailureLimiter,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aap-agent-server",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Server middleware for Agent Attestation Protocol - verify AI agents",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -21,7 +21,8 @@
21
21
  },
22
22
  "homepage": "https://github.com/ira-hash/agent-attestation-protocol#readme",
23
23
  "dependencies": {
24
- "aap-agent-core": "^2.6.0"
24
+ "aap-agent-core": "^2.6.0",
25
+ "ws": "^8.16.0"
25
26
  },
26
27
  "peerDependencies": {
27
28
  "express": "^4.18.0 || ^5.0.0"
package/websocket.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * AAP WebSocket Server v2.7
3
+ *
4
+ * Sequential challenge delivery over persistent connection.
5
+ * Humans cannot preview questions - each arrives with strict time limit.
6
+ */
7
+
8
+ import { WebSocketServer } from 'ws';
9
+ import { randomBytes, createVerify } from 'node:crypto';
10
+ import { generate as generateChallenge, getTypes } from './challenges.js';
11
+
12
+ // Constants
13
+ const CHALLENGE_COUNT = 7;
14
+ const TIME_PER_CHALLENGE_MS = 1200; // 1.2 seconds per challenge
15
+ const CONNECTION_TIMEOUT_MS = 15000; // 15 seconds total
16
+ const PROTOCOL_VERSION = '2.7.0';
17
+
18
+ /**
19
+ * Create AAP WebSocket server
20
+ * @param {Object} options
21
+ * @param {number} [options.port] - Standalone port (if no httpServer)
22
+ * @param {Object} [options.httpServer] - Existing HTTP server
23
+ * @param {string} [options.path='/aap'] - WebSocket path
24
+ * @param {Function} [options.onVerified] - Callback on successful verification
25
+ * @param {Function} [options.onFailed] - Callback on failed verification
26
+ */
27
+ export function createAAPWebSocket(options = {}) {
28
+ const {
29
+ port,
30
+ httpServer,
31
+ path = '/aap',
32
+ onVerified = null,
33
+ onFailed = null,
34
+ challengeCount = CHALLENGE_COUNT,
35
+ timePerChallengeMs = TIME_PER_CHALLENGE_MS
36
+ } = options;
37
+
38
+ const wssOptions = httpServer
39
+ ? { server: httpServer, path }
40
+ : { port, path };
41
+
42
+ const wss = new WebSocketServer(wssOptions);
43
+
44
+ // Track active sessions
45
+ const sessions = new Map();
46
+
47
+ wss.on('connection', (ws, req) => {
48
+ const sessionId = randomBytes(16).toString('hex');
49
+ const nonce = randomBytes(16).toString('hex');
50
+ const startTime = Date.now();
51
+
52
+ const session = {
53
+ id: sessionId,
54
+ nonce,
55
+ ws,
56
+ startTime,
57
+ currentChallenge: 0,
58
+ challenges: [],
59
+ validators: [],
60
+ answers: [],
61
+ timings: [],
62
+ publicKey: null,
63
+ publicId: null,
64
+ challengeStartTime: null,
65
+ timeout: null,
66
+ connectionTimeout: null
67
+ };
68
+
69
+ sessions.set(sessionId, session);
70
+
71
+ // Connection timeout
72
+ session.connectionTimeout = setTimeout(() => {
73
+ sendError(ws, 'Connection timeout');
74
+ ws.close();
75
+ }, CONNECTION_TIMEOUT_MS);
76
+
77
+ // Generate all challenges upfront (but send one at a time)
78
+ const types = getTypes();
79
+ for (let i = 0; i < challengeCount; i++) {
80
+ const type = types[i % types.length];
81
+ const challenge = generateChallenge(nonce + i, type);
82
+ session.challenges.push({
83
+ id: i,
84
+ type,
85
+ challenge_string: challenge.challenge_string
86
+ });
87
+ session.validators.push(challenge.validate);
88
+ }
89
+
90
+ // Send handshake
91
+ send(ws, {
92
+ type: 'handshake',
93
+ sessionId,
94
+ nonce,
95
+ protocol: 'AAP',
96
+ version: PROTOCOL_VERSION,
97
+ mode: 'websocket',
98
+ challengeCount,
99
+ timePerChallengeMs,
100
+ message: 'Connected. Send "ready" with publicKey to begin.'
101
+ });
102
+
103
+ ws.on('message', (data) => {
104
+ try {
105
+ const msg = JSON.parse(data.toString());
106
+ handleMessage(session, msg, { onVerified, onFailed, timePerChallengeMs });
107
+ } catch (e) {
108
+ sendError(ws, 'Invalid JSON');
109
+ }
110
+ });
111
+
112
+ ws.on('close', () => {
113
+ clearTimeout(session.timeout);
114
+ clearTimeout(session.connectionTimeout);
115
+ sessions.delete(sessionId);
116
+ });
117
+
118
+ ws.on('error', () => {
119
+ sessions.delete(sessionId);
120
+ });
121
+ });
122
+
123
+ return {
124
+ wss,
125
+ sessions,
126
+ close: () => wss.close()
127
+ };
128
+ }
129
+
130
+ function send(ws, data) {
131
+ if (ws.readyState === 1) { // OPEN
132
+ ws.send(JSON.stringify(data));
133
+ }
134
+ }
135
+
136
+ function sendError(ws, message, code = 'ERROR') {
137
+ send(ws, { type: 'error', code, message });
138
+ }
139
+
140
+ function handleMessage(session, msg, options) {
141
+ const { ws, currentChallenge, challenges, validators, answers, timings } = session;
142
+ const { onVerified, onFailed, timePerChallengeMs } = options;
143
+
144
+ switch (msg.type) {
145
+ case 'ready':
146
+ // Client ready to start, optionally provides identity
147
+ if (currentChallenge !== 0) {
148
+ sendError(ws, 'Already started');
149
+ return;
150
+ }
151
+
152
+ session.publicKey = msg.publicKey || null;
153
+ session.publicId = msg.publicId || null;
154
+
155
+ // Send first challenge
156
+ sendNextChallenge(session, timePerChallengeMs);
157
+ break;
158
+
159
+ case 'answer':
160
+ // Client submitting answer
161
+ if (session.challengeStartTime === null) {
162
+ sendError(ws, 'No active challenge');
163
+ return;
164
+ }
165
+
166
+ const responseTime = Date.now() - session.challengeStartTime;
167
+ clearTimeout(session.timeout);
168
+
169
+ // Check if too slow
170
+ if (responseTime > timePerChallengeMs) {
171
+ send(ws, {
172
+ type: 'timeout',
173
+ challengeId: currentChallenge,
174
+ responseTimeMs: responseTime,
175
+ limit: timePerChallengeMs
176
+ });
177
+ finishSession(session, false, 'Too slow on challenge ' + currentChallenge, { onVerified, onFailed });
178
+ return;
179
+ }
180
+
181
+ // Record answer and timing
182
+ answers.push(msg.answer);
183
+ timings.push(responseTime);
184
+
185
+ // Validate immediately
186
+ const valid = validators[currentChallenge](msg.answer);
187
+
188
+ send(ws, {
189
+ type: 'ack',
190
+ challengeId: currentChallenge,
191
+ responseTimeMs: responseTime,
192
+ valid // Real-time feedback
193
+ });
194
+
195
+ session.currentChallenge++;
196
+
197
+ // More challenges?
198
+ if (session.currentChallenge < challenges.length) {
199
+ // Small delay then next challenge
200
+ setTimeout(() => sendNextChallenge(session, timePerChallengeMs), 100);
201
+ } else {
202
+ // All done - calculate result
203
+ const passed = answers.filter((_, i) => validators[i](answers[i])).length;
204
+ const allPassed = passed === challenges.length;
205
+ const totalTime = Date.now() - session.startTime;
206
+
207
+ finishSession(session, allPassed, allPassed ? 'Verified' : `Failed: ${passed}/${challenges.length}`, {
208
+ onVerified,
209
+ onFailed,
210
+ passed,
211
+ total: challenges.length,
212
+ timings,
213
+ totalTime
214
+ });
215
+ }
216
+ break;
217
+
218
+ default:
219
+ sendError(ws, 'Unknown message type: ' + msg.type);
220
+ }
221
+ }
222
+
223
+ function sendNextChallenge(session, timePerChallengeMs) {
224
+ const { ws, currentChallenge, challenges } = session;
225
+ const challenge = challenges[currentChallenge];
226
+
227
+ session.challengeStartTime = Date.now();
228
+
229
+ send(ws, {
230
+ type: 'challenge',
231
+ id: currentChallenge,
232
+ total: challenges.length,
233
+ challenge: challenge.challenge_string,
234
+ timeLimit: timePerChallengeMs
235
+ });
236
+
237
+ // Timeout for this challenge
238
+ session.timeout = setTimeout(() => {
239
+ send(ws, {
240
+ type: 'timeout',
241
+ challengeId: currentChallenge,
242
+ message: 'Time expired'
243
+ });
244
+ finishSession(session, false, 'Timeout on challenge ' + currentChallenge, {});
245
+ }, timePerChallengeMs + 100); // Small grace period for network
246
+ }
247
+
248
+ function finishSession(session, success, message, options) {
249
+ const { ws, nonce, publicId, startTime } = session;
250
+ const { onVerified, onFailed, passed, total, timings, totalTime } = options;
251
+
252
+ clearTimeout(session.timeout);
253
+ clearTimeout(session.connectionTimeout);
254
+
255
+ const result = {
256
+ type: 'result',
257
+ verified: success,
258
+ nonce,
259
+ publicId,
260
+ message,
261
+ passed,
262
+ total,
263
+ timings,
264
+ totalTimeMs: totalTime || (Date.now() - startTime),
265
+ avgResponseMs: timings?.length ? Math.round(timings.reduce((a, b) => a + b, 0) / timings.length) : null
266
+ };
267
+
268
+ if (success) {
269
+ result.role = 'AI_AGENT';
270
+ result.sessionToken = randomBytes(32).toString('hex');
271
+ if (onVerified) onVerified(result, session);
272
+ } else {
273
+ if (onFailed) onFailed(result, session);
274
+ }
275
+
276
+ send(ws, result);
277
+
278
+ // Close after sending result
279
+ setTimeout(() => ws.close(), 500);
280
+ }
281
+
282
+ export default { createAAPWebSocket };