aap-agent-server 2.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aap-agent-server",
3
- "version": "2.5.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.5.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/persistence.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * AAP Challenge Persistence
3
+ *
4
+ * Optional: Persist challenges to survive server restarts
5
+ * Supports: Memory (default), File, Redis
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+
11
+ /**
12
+ * Create in-memory store (default, no persistence)
13
+ */
14
+ export function createMemoryStore() {
15
+ const challenges = new Map();
16
+
17
+ return {
18
+ type: 'memory',
19
+
20
+ async get(nonce) {
21
+ return challenges.get(nonce) || null;
22
+ },
23
+
24
+ async set(nonce, data) {
25
+ challenges.set(nonce, data);
26
+ },
27
+
28
+ async delete(nonce) {
29
+ challenges.delete(nonce);
30
+ },
31
+
32
+ async has(nonce) {
33
+ return challenges.has(nonce);
34
+ },
35
+
36
+ async size() {
37
+ return challenges.size;
38
+ },
39
+
40
+ async keys() {
41
+ return [...challenges.keys()];
42
+ },
43
+
44
+ async clear() {
45
+ challenges.clear();
46
+ },
47
+
48
+ async cleanup(now = Date.now()) {
49
+ for (const [nonce, data] of challenges.entries()) {
50
+ if (now > data.expiresAt) {
51
+ challenges.delete(nonce);
52
+ }
53
+ }
54
+ }
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Create file-based store (survives restarts)
60
+ * @param {string} filePath - Path to store file
61
+ */
62
+ export function createFileStore(filePath = '.aap/challenges.json') {
63
+ const fullPath = join(process.cwd(), filePath);
64
+ let challenges = new Map();
65
+
66
+ // Load existing data
67
+ try {
68
+ if (existsSync(fullPath)) {
69
+ const data = JSON.parse(readFileSync(fullPath, 'utf8'));
70
+ challenges = new Map(Object.entries(data));
71
+ console.log(`[AAP] Loaded ${challenges.size} challenges from ${fullPath}`);
72
+ }
73
+ } catch (error) {
74
+ console.warn('[AAP] Could not load challenges:', error.message);
75
+ }
76
+
77
+ // Save to file
78
+ const save = () => {
79
+ try {
80
+ const dir = dirname(fullPath);
81
+ if (!existsSync(dir)) {
82
+ mkdirSync(dir, { recursive: true });
83
+ }
84
+
85
+ const data = Object.fromEntries(challenges);
86
+ // Remove validator functions (not serializable)
87
+ for (const key of Object.keys(data)) {
88
+ delete data[key].validators;
89
+ }
90
+
91
+ writeFileSync(fullPath, JSON.stringify(data, null, 2));
92
+ } catch (error) {
93
+ console.error('[AAP] Could not save challenges:', error.message);
94
+ }
95
+ };
96
+
97
+ // Auto-save periodically
98
+ const saveInterval = setInterval(save, 30000);
99
+
100
+ return {
101
+ type: 'file',
102
+
103
+ async get(nonce) {
104
+ return challenges.get(nonce) || null;
105
+ },
106
+
107
+ async set(nonce, data) {
108
+ challenges.set(nonce, data);
109
+ save();
110
+ },
111
+
112
+ async delete(nonce) {
113
+ challenges.delete(nonce);
114
+ save();
115
+ },
116
+
117
+ async has(nonce) {
118
+ return challenges.has(nonce);
119
+ },
120
+
121
+ async size() {
122
+ return challenges.size;
123
+ },
124
+
125
+ async keys() {
126
+ return [...challenges.keys()];
127
+ },
128
+
129
+ async clear() {
130
+ challenges.clear();
131
+ save();
132
+ },
133
+
134
+ async cleanup(now = Date.now()) {
135
+ let cleaned = 0;
136
+ for (const [nonce, data] of challenges.entries()) {
137
+ if (now > data.expiresAt) {
138
+ challenges.delete(nonce);
139
+ cleaned++;
140
+ }
141
+ }
142
+ if (cleaned > 0) save();
143
+ return cleaned;
144
+ },
145
+
146
+ close() {
147
+ clearInterval(saveInterval);
148
+ save();
149
+ }
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Create Redis-based store (for distributed deployments)
155
+ * @param {Object} redisClient - Redis client instance (ioredis or redis)
156
+ * @param {string} [prefix='aap:challenge:'] - Key prefix
157
+ */
158
+ export function createRedisStore(redisClient, prefix = 'aap:challenge:') {
159
+ return {
160
+ type: 'redis',
161
+
162
+ async get(nonce) {
163
+ const data = await redisClient.get(prefix + nonce);
164
+ return data ? JSON.parse(data) : null;
165
+ },
166
+
167
+ async set(nonce, data, ttlMs = 60000) {
168
+ // Store without validators (not serializable)
169
+ const { validators, ...storable } = data;
170
+ await redisClient.set(
171
+ prefix + nonce,
172
+ JSON.stringify(storable),
173
+ 'PX',
174
+ ttlMs
175
+ );
176
+ },
177
+
178
+ async delete(nonce) {
179
+ await redisClient.del(prefix + nonce);
180
+ },
181
+
182
+ async has(nonce) {
183
+ return (await redisClient.exists(prefix + nonce)) === 1;
184
+ },
185
+
186
+ async size() {
187
+ const keys = await redisClient.keys(prefix + '*');
188
+ return keys.length;
189
+ },
190
+
191
+ async keys() {
192
+ const keys = await redisClient.keys(prefix + '*');
193
+ return keys.map(k => k.slice(prefix.length));
194
+ },
195
+
196
+ async clear() {
197
+ const keys = await redisClient.keys(prefix + '*');
198
+ if (keys.length > 0) {
199
+ await redisClient.del(...keys);
200
+ }
201
+ },
202
+
203
+ async cleanup() {
204
+ // Redis handles TTL automatically
205
+ return 0;
206
+ }
207
+ };
208
+ }
209
+
210
+ /**
211
+ * Auto-detect and create appropriate store
212
+ * @param {Object} options
213
+ * @param {'memory'|'file'|'redis'} [options.type='memory']
214
+ * @param {string} [options.filePath]
215
+ * @param {Object} [options.redisClient]
216
+ */
217
+ export function createStore(options = {}) {
218
+ const { type = 'memory', filePath, redisClient } = options;
219
+
220
+ switch (type) {
221
+ case 'file':
222
+ return createFileStore(filePath);
223
+ case 'redis':
224
+ if (!redisClient) {
225
+ throw new Error('Redis client required for redis store');
226
+ }
227
+ return createRedisStore(redisClient);
228
+ default:
229
+ return createMemoryStore();
230
+ }
231
+ }
232
+
233
+ export default {
234
+ createMemoryStore,
235
+ createFileStore,
236
+ createRedisStore,
237
+ createStore
238
+ };
package/ratelimit.js ADDED
@@ -0,0 +1,117 @@
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 };
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 };