a2acalling 0.6.65 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.6.65",
3
- "installed_at": "2026-02-25T07:27:45.973Z",
2
+ "version": "0.6.66",
3
+ "installed_at": "2026-02-25T09:43:06.371Z",
4
4
  "files": [
5
5
  {
6
6
  "path": "CLAUDE.md",
package/bin/cli.js CHANGED
@@ -1103,17 +1103,18 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1103
1103
  console.log('Legend: 🌐 public šŸ”§ friends ⚔ family');
1104
1104
  },
1105
1105
 
1106
- 'contacts:add': (args) => {
1106
+ 'contacts:add': async (args) => {
1107
1107
  const url = args._[2];
1108
1108
  if (!url) {
1109
1109
  console.error('Usage: a2a contacts add <invite_url> [options]');
1110
1110
  console.error('Options:');
1111
- console.error(' --name, -n Agent name');
1112
- console.error(' --owner, -o Owner name');
1113
- console.error(' --server-name Server label (optional)');
1114
- console.error(' --notes Notes about this contact');
1115
- console.error(' --tags Comma-separated tags');
1116
- console.error(' --link Link to token ID you gave them');
1111
+ console.error(' --name, -n Agent name');
1112
+ console.error(' --owner, -o Owner name');
1113
+ console.error(' --server-name Server label (optional)');
1114
+ console.error(' --notes Notes about this contact');
1115
+ console.error(' --tags Comma-separated tags');
1116
+ console.error(' --link Link to token ID you gave them');
1117
+ console.error(' --public-key Ed25519 public key (base64, or "fetch" to get from /status)');
1117
1118
  process.exit(1);
1118
1119
  }
1119
1120
 
@@ -1126,6 +1127,24 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1126
1127
  linkedTokenId: args.flags.link || null
1127
1128
  };
1128
1129
 
1130
+ // A2A-52: fetch or accept public key for identity verification
1131
+ const pubKeyFlag = args.flags['public-key'] || args.flags.public_key || args.flags.publicKey;
1132
+ if (pubKeyFlag === 'fetch' || pubKeyFlag === true) {
1133
+ try {
1134
+ const client = new A2AClient({});
1135
+ const statusResult = await client.status(url);
1136
+ if (statusResult.public_key) {
1137
+ options.public_key = statusResult.public_key;
1138
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
1139
+ console.log(` Fetched public key: ${fpFunc(statusResult.public_key)}`);
1140
+ }
1141
+ } catch (fetchErr) {
1142
+ console.error(` Warning: could not fetch public key from /status: ${fetchErr.message}`);
1143
+ }
1144
+ } else if (pubKeyFlag && typeof pubKeyFlag === 'string') {
1145
+ options.public_key = pubKeyFlag;
1146
+ }
1147
+
1129
1148
  try {
1130
1149
  const result = store.addContact(url, options);
1131
1150
  if (!result.success) {
@@ -1186,13 +1205,22 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1186
1205
  console.log(`šŸ” No linked token (you haven't given them access yet)`);
1187
1206
  }
1188
1207
 
1208
+ // A2A-52: show cryptographic identity verification status
1209
+ if (remote.public_key) {
1210
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
1211
+ console.log(`šŸ”‘ Identity: verified`);
1212
+ console.log(` Fingerprint: ${fpFunc(remote.public_key)}`);
1213
+ } else {
1214
+ console.log(`šŸ”‘ Identity: unverified (no public key pinned)`);
1215
+ }
1216
+
1189
1217
  if (remote.tags && remote.tags.length > 0) {
1190
1218
  console.log(`šŸ·ļø Tags: ${remote.tags.join(', ')}`);
1191
1219
  }
1192
1220
  if (remote.notes) {
1193
1221
  console.log(`šŸ“ Notes: ${remote.notes}`);
1194
1222
  }
1195
-
1223
+
1196
1224
  console.log(`\nšŸ“… Added: ${new Date(remote.added_at).toLocaleDateString()}`);
1197
1225
  if (remote.last_seen) {
1198
1226
  console.log(`šŸ“ Last seen: ${formatTimeAgo(new Date(remote.last_seen))}`);
@@ -1300,6 +1328,23 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1300
1328
  console.log(`🟢 ${remote.name} is online`);
1301
1329
  console.log(` Agent: ${result.name}`);
1302
1330
  console.log(` Version: ${result.version}`);
1331
+
1332
+ // A2A-52: also fetch /status to refresh public key
1333
+ try {
1334
+ const statusResult = await client.status(url);
1335
+ if (statusResult.public_key) {
1336
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
1337
+ if (remote.public_key && remote.public_key !== statusResult.public_key) {
1338
+ console.log(` āš ļø Public key changed!`);
1339
+ console.log(` Old: ${fpFunc(remote.public_key)}`);
1340
+ console.log(` New: ${fpFunc(statusResult.public_key)}`);
1341
+ }
1342
+ store.updateContact(name, { public_key: statusResult.public_key });
1343
+ console.log(` šŸ”‘ Fingerprint: ${fpFunc(statusResult.public_key)}`);
1344
+ }
1345
+ } catch (_) {
1346
+ // /status fetch is best-effort during ping
1347
+ }
1303
1348
  } catch (err) {
1304
1349
  store.updateContactStatus(name, 'offline', err.message);
1305
1350
  console.log(`šŸ”“ ${remote.name} is offline`);
@@ -1535,6 +1580,8 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1535
1580
  // Best effort
1536
1581
  }
1537
1582
 
1583
+ // A2A-52: load keypair for request signing in multi-turn calls
1584
+ const _multiKeypair = config.getKeypair();
1538
1585
  const driver = new ConversationDriver({
1539
1586
  runtime,
1540
1587
  agentContext,
@@ -1546,6 +1593,8 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1546
1593
  maxTurns,
1547
1594
  configTurnTimeoutMs,
1548
1595
  ownerContext,
1596
+ privateKey: _multiKeypair ? _multiKeypair.privateKey : null,
1597
+ publicKey: _multiKeypair ? _multiKeypair.publicKey : null,
1549
1598
  onTurn: (info) => {
1550
1599
  const preview = info.messagePreview.length >= 80
1551
1600
  ? info.messagePreview + '...'
@@ -1586,8 +1635,12 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
1586
1635
  }
1587
1636
 
1588
1637
  // Single-shot call (existing behavior)
1638
+ // A2A-52: load keypair for request signing
1639
+ const _callKeypair = config.getKeypair();
1589
1640
  const client = new A2AClient({
1590
- caller: { name: callerName }
1641
+ caller: { name: callerName },
1642
+ privateKey: _callKeypair ? _callKeypair.privateKey : null,
1643
+ publicKey: _callKeypair ? _callKeypair.publicKey : null
1591
1644
  });
1592
1645
 
1593
1646
  try {
@@ -2292,6 +2345,21 @@ a2a add "${inviteUrl}" "${ownerText || 'friend'}" && a2a call "${ownerText || 'f
2292
2345
  } catch (_) {}
2293
2346
  }
2294
2347
 
2348
+ // A2A-52: Generate Ed25519 keypair for cryptographic identity (skip if already exists)
2349
+ const existingKeypair = config.getKeypair();
2350
+ if (!existingKeypair) {
2351
+ const { generateKeypair, fingerprint: fpFunc } = require('../src/lib/crypto');
2352
+ const keypair = generateKeypair();
2353
+ config.setKeypair(keypair.privateKey, keypair.publicKey);
2354
+ const fp = fpFunc(keypair.publicKey);
2355
+ console.log(`\n šŸ”‘ Ed25519 identity generated`);
2356
+ console.log(` Fingerprint: ${fp}`);
2357
+ } else {
2358
+ const { fingerprint: fpFunc } = require('../src/lib/crypto');
2359
+ console.log(`\n šŸ”‘ Ed25519 identity exists (not overwritten)`);
2360
+ console.log(` Fingerprint: ${fpFunc(existingKeypair.publicKey)}`);
2361
+ }
2362
+
2295
2363
  // Save server config and advance onboarding state to awaiting_disclosure.
2296
2364
  config.setAgent({ hostname: publicHost });
2297
2365
  config.setOnboarding({ step: 'awaiting_disclosure' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "a2acalling",
3
- "version": "0.6.65",
3
+ "version": "0.6.66",
4
4
  "description": "Agent-to-agent calling for OpenClaw - A2A agent communication",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -1535,7 +1535,12 @@ function handleZoneDrop(zone, e) {
1535
1535
  // since autoSaveTier() may refresh state.settings asynchronously.
1536
1536
  setTimeout(() => {
1537
1537
  const freshTier = (state.settings?.tiers || []).find(t => t.id === state.activeTierId);
1538
- if (freshTier) renderSidebarLists(freshTier);
1538
+ if (freshTier) {
1539
+ renderSidebarLists(freshTier);
1540
+ // A2A-51: Re-bind drag listeners after innerHTML replacement in renderSidebarLists().
1541
+ // Without this, sidebar items lose dragstart/dragend handlers after the first drop.
1542
+ bindSidebarDrag();
1543
+ }
1539
1544
  }, 300);
1540
1545
  }
1541
1546
 
@@ -1776,8 +1781,9 @@ function bindPermissionsActions() {
1776
1781
  return;
1777
1782
  }
1778
1783
 
1779
- // A2A-48: Sidebar "Add Topic" / "Add Goal" buttons open create dialog
1780
- const addBtn = e.target.closest('.sidebar-add-btn[data-add-type]');
1784
+ // A2A-48: Sidebar "Add Topic" / "Add Goal" buttons open create dialog.
1785
+ // A2A-51: Also matches .col-header-add-btn for narrow viewports where sidebar is hidden.
1786
+ const addBtn = e.target.closest('[data-add-type]');
1781
1787
  if (addBtn) {
1782
1788
  const type = addBtn.dataset.addType;
1783
1789
  const dialog = document.getElementById('create-item-dialog');
@@ -1798,13 +1804,17 @@ function bindPermissionsActions() {
1798
1804
  // (#active-topics-zone, #active-goals-zone) persist across renders. Only
1799
1805
  // their innerHTML is replaced by renderActiveTopics/renderActiveGoals.
1800
1806
  // Binding in bindSidebarDrag() would cause listener accumulation.
1807
+ // A2A-51: Uses dragenter/dragleave counter to prevent flickering when
1808
+ // cursor moves over child elements (cards, placeholder) inside the zone.
1801
1809
  const topicZone = document.getElementById('active-topics-zone');
1802
1810
  const goalZone = document.getElementById('active-goals-zone');
1803
1811
  [topicZone, goalZone].forEach(zone => {
1804
1812
  if (!zone) return;
1805
- zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('drag-over'); });
1806
- zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
1807
- zone.addEventListener('drop', (e) => handleZoneDrop(zone, e));
1813
+ let dragCounter = 0;
1814
+ zone.addEventListener('dragenter', (e) => { e.preventDefault(); dragCounter++; zone.classList.add('drag-over'); });
1815
+ zone.addEventListener('dragover', (e) => { e.preventDefault(); });
1816
+ zone.addEventListener('dragleave', () => { dragCounter--; if (dragCounter === 0) zone.classList.remove('drag-over'); });
1817
+ zone.addEventListener('drop', (e) => { dragCounter = 0; handleZoneDrop(zone, e); });
1808
1818
  });
1809
1819
 
1810
1820
  // A2A-48: Tool toggle change — auto-save and update card styling
@@ -130,6 +130,7 @@
130
130
  <span class="status-dot status-dot--teal"></span>
131
131
  Active Topics
132
132
  <span id="topic-count" class="count-badge count-badge--teal">0</span>
133
+ <button class="col-header-add-btn" data-add-type="topic" title="Add topic"><span class="material-symbols-outlined" style="font-size:16px;">add</span></button>
133
134
  </div>
134
135
  <div id="active-topics-zone" class="perm-drop-zone"></div>
135
136
  </div>
@@ -138,6 +139,7 @@
138
139
  <span class="status-dot status-dot--yellow"></span>
139
140
  Active Goals
140
141
  <span id="goal-count" class="count-badge count-badge--yellow">0</span>
142
+ <button class="col-header-add-btn" data-add-type="goal" title="Add goal"><span class="material-symbols-outlined" style="font-size:16px;">add</span></button>
141
143
  </div>
142
144
  <div id="active-goals-zone" class="perm-drop-zone"></div>
143
145
  </div>
@@ -697,6 +697,25 @@ table tbody tr:hover td {
697
697
  padding: 0 0.25rem;
698
698
  }
699
699
 
700
+ /* A2A-51: "+" button in column headers for adding topics/goals without sidebar */
701
+ .col-header-add-btn {
702
+ margin-left: auto;
703
+ background: none;
704
+ border: 1px solid rgba(255,255,255,0.12);
705
+ border-radius: 4px;
706
+ color: var(--ink-muted);
707
+ cursor: pointer;
708
+ padding: 1px 4px;
709
+ display: flex;
710
+ align-items: center;
711
+ transition: color 0.15s ease, border-color 0.15s ease;
712
+ }
713
+
714
+ .col-header-add-btn:hover {
715
+ color: var(--ink);
716
+ border-color: rgba(255,255,255,0.3);
717
+ }
718
+
700
719
  .config-col-header--teal {
701
720
  color: #2DD4BF;
702
721
  }
package/src/lib/client.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  const https = require('https');
6
6
  const http = require('http');
7
+ const { signRequest } = require('./crypto');
7
8
 
8
9
  function splitHostPort(rawHost) {
9
10
  const host = String(rawHost || '').trim();
@@ -54,6 +55,24 @@ class A2AClient {
54
55
  constructor(options = {}) {
55
56
  this.timeout = options.timeout || 60000;
56
57
  this.caller = options.caller || {};
58
+ // A2A-52: Ed25519 identity keys for request signing
59
+ this.privateKey = options.privateKey || null;
60
+ this.publicKey = options.publicKey || null;
61
+ }
62
+
63
+ /**
64
+ * A2A-52: Build signature headers if keypair is available.
65
+ * Shared helper used by both call() and end().
66
+ */
67
+ _signHeaders(method, endpoint, body) {
68
+ if (!this.privateKey || !this.publicKey) return {};
69
+ return signRequest({
70
+ privateKey: this.privateKey,
71
+ publicKey: this.publicKey,
72
+ method,
73
+ endpoint,
74
+ body
75
+ });
57
76
  }
58
77
 
59
78
  /**
@@ -95,6 +114,8 @@ class A2AClient {
95
114
  });
96
115
 
97
116
  const { protocol, hostname, port } = resolveProtocolAndPort(host);
117
+ // A2A-52: attach signature headers when keypair available
118
+ const sigHeaders = this._signHeaders('POST', '/api/a2a/invoke', body);
98
119
 
99
120
  return new Promise((resolve, reject) => {
100
121
  const req = protocol.request({
@@ -105,7 +126,8 @@ class A2AClient {
105
126
  headers: {
106
127
  'Authorization': `Bearer ${token}`,
107
128
  'Content-Type': 'application/json',
108
- 'Content-Length': Buffer.byteLength(body)
129
+ 'Content-Length': Buffer.byteLength(body),
130
+ ...sigHeaders
109
131
  },
110
132
  timeout: this.timeout
111
133
  }, (res) => {
@@ -141,7 +163,7 @@ class A2AClient {
141
163
 
142
164
  /**
143
165
  * Explicitly end a remote conversation and trigger call conclusion
144
- *
166
+ *
145
167
  * @param {string|object} endpoint - a2a:// URL or {host, token}
146
168
  * @param {string} conversationId - Conversation ID to conclude
147
169
  * @returns {Promise<object>} End response from remote agent
@@ -152,7 +174,7 @@ class A2AClient {
152
174
  }
153
175
 
154
176
  let host, token;
155
-
177
+
156
178
  if (typeof endpoint === 'string') {
157
179
  ({ host, token } = A2AClient.parseInvite(endpoint));
158
180
  } else {
@@ -164,6 +186,8 @@ class A2AClient {
164
186
  });
165
187
 
166
188
  const { protocol, hostname, port } = resolveProtocolAndPort(host);
189
+ // A2A-52: attach signature headers when keypair available
190
+ const sigHeaders = this._signHeaders('POST', '/api/a2a/end', body);
167
191
 
168
192
  return new Promise((resolve, reject) => {
169
193
  const req = protocol.request({
@@ -174,7 +198,8 @@ class A2AClient {
174
198
  headers: {
175
199
  'Authorization': `Bearer ${token}`,
176
200
  'Content-Type': 'application/json',
177
- 'Content-Length': Buffer.byteLength(body)
201
+ 'Content-Length': Buffer.byteLength(body),
202
+ ...sigHeaders
178
203
  },
179
204
  timeout: this.timeout
180
205
  }, (res) => {
package/src/lib/config.js CHANGED
@@ -230,10 +230,13 @@ const DEFAULT_CONFIG = {
230
230
  },
231
231
 
232
232
  // Agent info
233
+ // A2A-52: private_key/public_key store Ed25519 identity (base64 DER)
233
234
  agent: {
234
235
  name: '',
235
236
  description: '',
236
- hostname: ''
237
+ hostname: '',
238
+ private_key: null,
239
+ public_key: null
237
240
  },
238
241
 
239
242
  // Auto-updater
@@ -386,6 +389,21 @@ class A2AConfig {
386
389
  this._save();
387
390
  }
388
391
 
392
+ // A2A-52: Get Ed25519 keypair from agent config (null if not generated)
393
+ getKeypair() {
394
+ const agent = this.config.agent || {};
395
+ if (!agent.private_key || !agent.public_key) return null;
396
+ return { privateKey: agent.private_key, publicKey: agent.public_key };
397
+ }
398
+
399
+ // A2A-52: Store Ed25519 keypair in agent config (already 0o600 via _save)
400
+ setKeypair(privateKey, publicKey) {
401
+ this.config.agent = this.config.agent || {};
402
+ this.config.agent.private_key = privateKey;
403
+ this.config.agent.public_key = publicKey;
404
+ this._save();
405
+ }
406
+
389
407
  // Get full config
390
408
  getAll() {
391
409
  return this.config;
@@ -424,12 +442,13 @@ class A2AConfig {
424
442
  return next;
425
443
  }
426
444
 
427
- // Export for sharing
445
+ // Export for sharing (strips private_key to prevent leakage — A2A-52)
428
446
  export() {
447
+ const { private_key, ...agentPublic } = this.config.agent || {};
429
448
  return {
430
449
  tiers: this.config.tiers,
431
450
  defaults: this.config.defaults,
432
- agent: this.config.agent
451
+ agent: agentPublic
433
452
  };
434
453
  }
435
454
  }
@@ -147,7 +147,13 @@ class ConversationDriver {
147
147
  const clientTimeout = this.claudeMode
148
148
  ? Math.max(this.claudeTimeoutMs + 20000, 200000)
149
149
  : 65000;
150
- this.client = new A2AClient({ caller: this.caller, timeout: clientTimeout });
150
+ // A2A-52: pass Ed25519 keypair for request signing
151
+ this.client = new A2AClient({
152
+ caller: this.caller,
153
+ timeout: clientTimeout,
154
+ privateKey: options.privateKey || null,
155
+ publicKey: options.publicKey || null
156
+ });
151
157
  }
152
158
 
153
159
  /**
@@ -0,0 +1,113 @@
1
+ /**
2
+ * A2A Ed25519 Cryptographic Identity
3
+ *
4
+ * Provides keypair generation, request signing, signature verification,
5
+ * and public key fingerprinting for agent-to-agent identity verification.
6
+ *
7
+ * A2A-52: Zero new dependencies — uses Node.js built-in crypto (Ed25519 since v15).
8
+ */
9
+
10
+ const crypto = require('crypto');
11
+
12
+ // A2A-52: 5-minute window for replay protection
13
+ const TIMESTAMP_WINDOW_MS = 5 * 60 * 1000;
14
+
15
+ /**
16
+ * Generate an Ed25519 keypair.
17
+ * Returns { privateKey, publicKey } as base64-encoded DER buffers.
18
+ */
19
+ function generateKeypair() {
20
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
21
+ privateKeyEncoding: { type: 'pkcs8', format: 'der' },
22
+ publicKeyEncoding: { type: 'spki', format: 'der' }
23
+ });
24
+ return {
25
+ privateKey: privateKey.toString('base64'),
26
+ publicKey: publicKey.toString('base64')
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Compute a SHA-256 fingerprint of a base64-encoded public key.
32
+ * Returns colon-separated hex string (like SSH fingerprints).
33
+ */
34
+ function fingerprint(publicKeyBase64) {
35
+ const hash = crypto.createHash('sha256')
36
+ .update(Buffer.from(publicKeyBase64, 'base64'))
37
+ .digest('hex');
38
+ // A2A-52: colon-separated pairs for readability (SSH-style)
39
+ return hash.match(/.{2}/g).join(':');
40
+ }
41
+
42
+ /**
43
+ * Sign an outbound request.
44
+ *
45
+ * Signing payload: `${timestamp}:${method}:${endpoint}:${bodyHash}`
46
+ * where bodyHash = SHA-256 of the request body string.
47
+ *
48
+ * @param {object} params
49
+ * @param {string} params.privateKey - base64-encoded DER private key
50
+ * @param {string} params.publicKey - base64-encoded DER public key
51
+ * @param {string} params.method - HTTP method (e.g. 'POST')
52
+ * @param {string} params.endpoint - Request path (e.g. '/api/a2a/invoke')
53
+ * @param {string} params.body - Serialized request body
54
+ * @returns {object} Headers to attach: { 'X-A2A-Signature', 'X-A2A-Public-Key', 'X-A2A-Timestamp' }
55
+ */
56
+ function signRequest({ privateKey, publicKey, method, endpoint, body }) {
57
+ const timestamp = new Date().toISOString();
58
+ const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
59
+ const payload = `${timestamp}:${method}:${endpoint}:${bodyHash}`;
60
+
61
+ const keyObject = crypto.createPrivateKey({
62
+ key: Buffer.from(privateKey, 'base64'),
63
+ format: 'der',
64
+ type: 'pkcs8'
65
+ });
66
+
67
+ const signature = crypto.sign(null, Buffer.from(payload), keyObject);
68
+
69
+ return {
70
+ 'X-A2A-Signature': signature.toString('base64'),
71
+ 'X-A2A-Public-Key': publicKey,
72
+ 'X-A2A-Timestamp': timestamp
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Verify an inbound request signature.
78
+ *
79
+ * @param {object} params
80
+ * @param {string} params.signature - base64-encoded Ed25519 signature
81
+ * @param {string} params.publicKey - base64-encoded DER public key
82
+ * @param {string} params.timestamp - ISO 8601 timestamp from header
83
+ * @param {string} params.method - HTTP method
84
+ * @param {string} params.endpoint - Request path
85
+ * @param {string} params.body - Raw request body string
86
+ * @returns {boolean} true if signature is valid
87
+ */
88
+ function verifySignature({ signature, publicKey, timestamp, method, endpoint, body }) {
89
+ const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
90
+ const payload = `${timestamp}:${method}:${endpoint}:${bodyHash}`;
91
+
92
+ const keyObject = crypto.createPublicKey({
93
+ key: Buffer.from(publicKey, 'base64'),
94
+ format: 'der',
95
+ type: 'spki'
96
+ });
97
+
98
+ return crypto.verify(null, Buffer.from(payload), keyObject, Buffer.from(signature, 'base64'));
99
+ }
100
+
101
+ /**
102
+ * Check if a timestamp is within the allowed window (replay protection).
103
+ * @param {string} timestamp - ISO 8601 timestamp
104
+ * @returns {boolean} true if within +-5 minutes of now
105
+ */
106
+ function isTimestampValid(timestamp) {
107
+ const ts = new Date(timestamp).getTime();
108
+ if (Number.isNaN(ts)) return false;
109
+ const diff = Math.abs(Date.now() - ts);
110
+ return diff <= TIMESTAMP_WINDOW_MS;
111
+ }
112
+
113
+ module.exports = { generateKeypair, fingerprint, signRequest, verifySignature, isTimestampValid };
package/src/lib/tokens.js CHANGED
@@ -448,6 +448,7 @@ class TokenStore {
448
448
  tags: Array.isArray(options.tags) ? options.tags : [],
449
449
  fields: sanitizeCustomFields(options.fields || options.custom_fields || options.customFields),
450
450
  linked_token_id: options.linkedTokenId || options.linked_token_id || null, // Token you gave them
451
+ public_key: options.public_key || options.publicKey || null, // A2A-52: Ed25519 public key (base64 DER)
451
452
  status: 'unknown',
452
453
  last_seen: null,
453
454
  added_at: new Date().toISOString(),
@@ -597,7 +598,8 @@ class TokenStore {
597
598
  }
598
599
 
599
600
  // Only allow updating specific fields
600
- const allowed = ['name', 'owner', 'is_mine', 'notes', 'tags', 'linked_token_id', 'server_name', 'fields'];
601
+ // A2A-52: 'public_key' added for Ed25519 identity verification (TOFU pinning)
602
+ const allowed = ['name', 'owner', 'is_mine', 'notes', 'tags', 'linked_token_id', 'server_name', 'fields', 'public_key'];
601
603
  for (const key of allowed) {
602
604
  if (updates[key] !== undefined) {
603
605
  if (key === 'fields') {
@@ -764,6 +766,7 @@ class TokenStore {
764
766
  tags: ['inbound'],
765
767
  fields: {},
766
768
  linked_token_id: tokenId || null,
769
+ public_key: null, // A2A-52: populated via TOFU on first verified call
767
770
  status: 'unknown',
768
771
  last_seen: null,
769
772
  added_at: new Date().toISOString(),
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;