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/challenges.js +237 -77
- package/index.js +29 -3
- package/middleware.js +58 -13
- package/package.json +3 -2
- package/persistence.js +238 -0
- package/ratelimit.js +117 -0
- package/websocket.js +282 -0
- package/whitelist.js +231 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aap-agent-server",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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 };
|