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.
- package/.a2a-manifest.json +2 -2
- package/bin/cli.js +77 -9
- package/package.json +1 -1
- package/src/dashboard/public/app.js +16 -6
- package/src/dashboard/public/index.html +2 -0
- package/src/dashboard/public/style.css +19 -0
- package/src/lib/client.js +29 -4
- package/src/lib/config.js +22 -3
- package/src/lib/conversation-driver.js +7 -1
- package/src/lib/crypto.js +113 -0
- package/src/lib/tokens.js +4 -1
- package/src/routes/a2a.js +78 -4
- package/src/routes/dashboard.js +1 -1
- package/src/server.js +3 -0
package/.a2a-manifest.json
CHANGED
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
|
|
1112
|
-
console.error(' --owner, -o
|
|
1113
|
-
console.error(' --server-name
|
|
1114
|
-
console.error(' --notes
|
|
1115
|
-
console.error(' --tags
|
|
1116
|
-
console.error(' --link
|
|
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
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
1806
|
-
zone.addEventListener('
|
|
1807
|
-
zone.addEventListener('
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', {
|
package/src/routes/dashboard.js
CHANGED
|
@@ -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;
|