a2acalling 0.6.64 → 0.6.66

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/src/routes/a2a.js CHANGED
@@ -11,6 +11,7 @@
11
11
  const { TokenStore } = require('../lib/tokens');
12
12
  const crypto = require('crypto');
13
13
  const { createLogger, createTraceId } = require('../lib/logger');
14
+ const { verifySignature, isTimestampValid, fingerprint } = require('../lib/crypto');
14
15
 
15
16
  // Lazy-load conversation store (optional dependency)
16
17
  let ConversationStore = null;
@@ -187,19 +188,75 @@ function createRoutes(options = {}) {
187
188
  } catch (_) {}
188
189
  }
189
190
 
191
+ // A2A-52: shared signature verification helper for /invoke and /end
192
+ function verifySigHeaders(req, validation, endpoint, reqLogger, withTracePayload) {
193
+ const sigHeader = req.headers['x-a2a-signature'];
194
+ const pubKeyHeader = req.headers['x-a2a-public-key'];
195
+ const tsHeader = req.headers['x-a2a-timestamp'];
196
+ const result = { identityVerified: false, publicKeyFingerprint: null, error: null };
197
+
198
+ if (!sigHeader || !pubKeyHeader || !tsHeader) return result;
199
+
200
+ if (!isTimestampValid(tsHeader)) {
201
+ result.error = { status: 403, body: { success: false, error: 'timestamp_expired', message: 'Request timestamp outside allowed window' } };
202
+ reqLogger.warn('Signature timestamp outside window', { tokenId: validation.id, error_code: 'SIGNATURE_TIMESTAMP_EXPIRED', status_code: 403 });
203
+ return result;
204
+ }
205
+
206
+ try {
207
+ crypto.createPublicKey({ key: Buffer.from(pubKeyHeader, 'base64'), format: 'der', type: 'spki' });
208
+ } catch (_) {
209
+ result.error = { status: 400, body: { success: false, error: 'malformed_public_key', message: 'X-A2A-Public-Key is not a valid Ed25519 public key' } };
210
+ reqLogger.warn('Malformed public key', { tokenId: validation.id, error_code: 'MALFORMED_PUBLIC_KEY', status_code: 400 });
211
+ return result;
212
+ }
213
+
214
+ const existingContact = tokenStore.getContact(validation.id) ||
215
+ (tokenStore.listContacts().find(c => c.linked_token_id === validation.id));
216
+ if (existingContact && existingContact.public_key && existingContact.public_key !== pubKeyHeader) {
217
+ result.error = { status: 403, body: { success: false, error: 'public_key_mismatch', message: 'Public key does not match previously pinned key' } };
218
+ reqLogger.warn('Public key mismatch (TOFU violation)', { tokenId: validation.id, error_code: 'PUBLIC_KEY_MISMATCH', status_code: 403 });
219
+ return result;
220
+ }
221
+
222
+ const rawBody = JSON.stringify(req.body);
223
+ try {
224
+ const valid = verifySignature({ signature: sigHeader, publicKey: pubKeyHeader, timestamp: tsHeader, method: 'POST', endpoint, body: rawBody });
225
+ if (valid) {
226
+ result.identityVerified = true;
227
+ result.publicKeyFingerprint = fingerprint(pubKeyHeader);
228
+ if (existingContact && !existingContact.public_key) {
229
+ tokenStore.updateContact(existingContact.name || existingContact.id, { public_key: pubKeyHeader });
230
+ }
231
+ } else {
232
+ result.error = { status: 403, body: { success: false, error: 'invalid_signature', message: 'Ed25519 signature verification failed' } };
233
+ reqLogger.warn('Signature verification failed', { tokenId: validation.id, error_code: 'SIGNATURE_INVALID', status_code: 403 });
234
+ }
235
+ } catch (sigErr) {
236
+ result.error = { status: 403, body: { success: false, error: 'invalid_signature', message: 'Signature verification failed' } };
237
+ reqLogger.warn('Signature verification error', { tokenId: validation.id, error_code: 'SIGNATURE_VERIFY_ERROR', status_code: 403, error: sigErr });
238
+ }
239
+ return result;
240
+ }
241
+
190
242
  /**
191
243
  * GET /status
192
244
  * Check if A2A is enabled
193
245
  */
194
246
  router.get('/status', (req, res) => {
195
247
  const activeCalls = monitor ? monitor.getActiveCount() : 0;
196
- res.json({
248
+ const response = {
197
249
  a2a: true,
198
250
  version: require('../../package.json').version,
199
251
  capabilities: ['invoke', 'multi-turn'],
200
252
  rate_limits: limits,
201
253
  active_calls: activeCalls
202
- });
254
+ };
255
+ // A2A-52: include agent public key so contacts can fetch it
256
+ if (options.publicKey) {
257
+ response.public_key = options.publicKey;
258
+ }
259
+ res.json(response);
203
260
  });
204
261
 
205
262
  /**
@@ -291,6 +348,14 @@ function createRoutes(options = {}) {
291
348
  }));
292
349
  }
293
350
 
351
+ // A2A-52: Ed25519 signature verification (after token auth, before message handling)
352
+ const sigCheck = verifySigHeaders(req, validation, '/api/a2a/invoke', reqLogger, withTracePayload);
353
+ if (sigCheck.error) {
354
+ return res.status(sigCheck.error.status).json(withTracePayload(sigCheck.error.body));
355
+ }
356
+ const identityVerified = sigCheck.identityVerified;
357
+ const publicKeyFingerprint = sigCheck.publicKeyFingerprint;
358
+
294
359
  // Extract and validate request
295
360
  const { message, conversation_id, caller, context, timeout_seconds = 60 } = req.body;
296
361
 
@@ -353,7 +418,10 @@ function createRoutes(options = {}) {
353
418
  caller: sanitizedCaller,
354
419
  conversation_id: conversation_id || `conv_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`,
355
420
  trace_id: traceId,
356
- request_id: requestId
421
+ request_id: requestId,
422
+ // A2A-52: cryptographic identity verification status
423
+ identity_verified: identityVerified,
424
+ public_key_fingerprint: publicKeyFingerprint
357
425
  };
358
426
 
359
427
  // Ensure inbound caller exists as a contact (best-effort).
@@ -568,10 +636,16 @@ function createRoutes(options = {}) {
568
636
  return res.status(401).json(withTracePayload({
569
637
  success: false,
570
638
  error: 'unauthorized',
571
- message: 'Invalid or expired token'
639
+ message: 'Invalid or expired token'
572
640
  }));
573
641
  }
574
642
 
643
+ // A2A-52: Ed25519 signature verification for /end (same as /invoke)
644
+ const endSigCheck = verifySigHeaders(req, validation, '/api/a2a/end', reqLogger, withTracePayload);
645
+ if (endSigCheck.error) {
646
+ return res.status(endSigCheck.error.status).json(withTracePayload(endSigCheck.error.body));
647
+ }
648
+
575
649
  const { conversation_id } = req.body;
576
650
  if (!conversation_id) {
577
651
  reqLogger.warn('End request missing conversation_id', {
@@ -1331,7 +1331,7 @@ function createDashboardApiRouter(options = {}) {
1331
1331
  success: true,
1332
1332
  onboarding_complete: context.config.isOnboarded(),
1333
1333
  defaults: cfg.defaults || {},
1334
- agent: cfg.agent || {},
1334
+ agent: (() => { const { private_key, ...pub } = cfg.agent || {}; return pub; })(),
1335
1335
  tiers,
1336
1336
  manifest: {
1337
1337
  never_disclose: manifest.never_disclose || [],
package/src/server.js CHANGED
@@ -901,9 +901,12 @@ app.use('/dashboard', createDashboardUiRouter({
901
901
  }));
902
902
  app.use('/callbook', createCallbookRouter());
903
903
 
904
+ // A2A-52: pass agent's public key so /status can advertise it
905
+ const _a2aKeypair = config.getKeypair();
904
906
  app.use('/api/a2a', createRoutes({
905
907
  tokenStore,
906
908
  eventStore,
909
+ publicKey: _a2aKeypair ? _a2aKeypair.publicKey : null,
907
910
  logger: logger.child({ component: 'a2a.routes' }),
908
911
  onCallMonitor: (monitor) => {
909
912
  activeCallMonitor = monitor;