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/.a2a-manifest.json +2 -2
- package/CONVENTIONS.md +3 -0
- package/bin/cli.js +77 -9
- package/package.json +1 -1
- package/src/dashboard/public/app.js +220 -96
- package/src/dashboard/public/index.html +102 -71
- package/src/dashboard/public/style.css +33 -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
|
@@ -45,6 +45,11 @@
|
|
|
45
45
|
<span class="material-symbols-outlined nav-icon" style="color:#EF4444;">monitor_heart</span>
|
|
46
46
|
<span class="nav-label">Health</span>
|
|
47
47
|
</a>
|
|
48
|
+
<!-- A2A-50: Settings nav item for relocated admin settings -->
|
|
49
|
+
<a data-panel="settings" class="nav-item">
|
|
50
|
+
<span class="material-symbols-outlined nav-icon" style="color:#6B7280;">settings</span>
|
|
51
|
+
<span class="nav-label">Settings</span>
|
|
52
|
+
</a>
|
|
48
53
|
</nav>
|
|
49
54
|
</aside>
|
|
50
55
|
|
|
@@ -125,6 +130,7 @@
|
|
|
125
130
|
<span class="status-dot status-dot--teal"></span>
|
|
126
131
|
Active Topics
|
|
127
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>
|
|
128
134
|
</div>
|
|
129
135
|
<div id="active-topics-zone" class="perm-drop-zone"></div>
|
|
130
136
|
</div>
|
|
@@ -133,6 +139,7 @@
|
|
|
133
139
|
<span class="status-dot status-dot--yellow"></span>
|
|
134
140
|
Active Goals
|
|
135
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>
|
|
136
143
|
</div>
|
|
137
144
|
<div id="active-goals-zone" class="perm-drop-zone"></div>
|
|
138
145
|
</div>
|
|
@@ -147,77 +154,6 @@
|
|
|
147
154
|
|
|
148
155
|
<!-- Tier Warnings -->
|
|
149
156
|
<div id="tier-warnings" class="tier-warnings"></div>
|
|
150
|
-
|
|
151
|
-
<!-- Settings & Administration (collapsed) -->
|
|
152
|
-
<sl-details summary="Settings & Administration">
|
|
153
|
-
<h3>Defaults</h3>
|
|
154
|
-
<form id="defaults-form">
|
|
155
|
-
<sl-input id="defaults-expiration" label="Expiration" placeholder="7d"></sl-input>
|
|
156
|
-
<sl-input id="defaults-max-calls" label="Max Calls" type="number" min="1"></sl-input>
|
|
157
|
-
<div class="row">
|
|
158
|
-
<sl-button type="submit" variant="primary">Save Defaults</sl-button>
|
|
159
|
-
</div>
|
|
160
|
-
</form>
|
|
161
|
-
|
|
162
|
-
<h3>New Tier</h3>
|
|
163
|
-
<form id="new-tier-form">
|
|
164
|
-
<sl-input id="new-tier-id" label="Tier ID" placeholder="partners"></sl-input>
|
|
165
|
-
<sl-input id="new-tier-name" label="Name" placeholder="Partners"></sl-input>
|
|
166
|
-
<label>Copy from
|
|
167
|
-
<sl-select id="new-tier-copy-from" size="small">
|
|
168
|
-
<sl-option value="">None</sl-option>
|
|
169
|
-
</sl-select>
|
|
170
|
-
</label>
|
|
171
|
-
<div class="row">
|
|
172
|
-
<sl-button type="submit" variant="primary">Create Tier</sl-button>
|
|
173
|
-
</div>
|
|
174
|
-
</form>
|
|
175
|
-
|
|
176
|
-
<h3>Remote Callbook</h3>
|
|
177
|
-
<sl-card id="callbook-status"></sl-card>
|
|
178
|
-
|
|
179
|
-
<h3>Auto Update</h3>
|
|
180
|
-
<sl-card id="auto-update-status">Loading...</sl-card>
|
|
181
|
-
<div class="row">
|
|
182
|
-
<sl-button id="auto-update-check" size="small">Check now</sl-button>
|
|
183
|
-
<sl-button id="auto-update-now" size="small">Update now</sl-button>
|
|
184
|
-
<sl-button id="auto-update-toggle" size="small">Disable auto-update</sl-button>
|
|
185
|
-
</div>
|
|
186
|
-
|
|
187
|
-
<sl-card>
|
|
188
|
-
<form id="callbook-provision-form">
|
|
189
|
-
<div class="row">
|
|
190
|
-
<sl-button type="submit" variant="primary" size="small">Create Install Link (24h)</sl-button>
|
|
191
|
-
<sl-button id="callbook-logout" size="small" variant="default">Logout This Browser</sl-button>
|
|
192
|
-
</div>
|
|
193
|
-
<sl-input id="callbook-label" label="Device label" value="Callbook Remote"></sl-input>
|
|
194
|
-
<sl-textarea id="callbook-install-url" label="Install URL" rows="3" readonly></sl-textarea>
|
|
195
|
-
<div class="row">
|
|
196
|
-
<sl-button id="callbook-copy-url" size="small">Copy Link</sl-button>
|
|
197
|
-
</div>
|
|
198
|
-
<div id="callbook-warnings" class="mono"></div>
|
|
199
|
-
</form>
|
|
200
|
-
</sl-card>
|
|
201
|
-
|
|
202
|
-
<sl-card>
|
|
203
|
-
<div class="row">
|
|
204
|
-
<strong>Paired Devices</strong>
|
|
205
|
-
</div>
|
|
206
|
-
<table id="callbook-devices-table">
|
|
207
|
-
<thead>
|
|
208
|
-
<tr>
|
|
209
|
-
<th>Label</th>
|
|
210
|
-
<th>Created</th>
|
|
211
|
-
<th>Last Used</th>
|
|
212
|
-
<th>Sessions</th>
|
|
213
|
-
<th>Revoked</th>
|
|
214
|
-
<th>Action</th>
|
|
215
|
-
</tr>
|
|
216
|
-
</thead>
|
|
217
|
-
<tbody></tbody>
|
|
218
|
-
</table>
|
|
219
|
-
</sl-card>
|
|
220
|
-
</sl-details>
|
|
221
157
|
</div>
|
|
222
158
|
|
|
223
159
|
<!-- Right sidebar: preview + topic/goal lists -->
|
|
@@ -243,6 +179,30 @@
|
|
|
243
179
|
<div id="preview-content"></div>
|
|
244
180
|
<sl-button slot="footer" variant="primary" id="preview-close-btn">Close</sl-button>
|
|
245
181
|
</sl-dialog>
|
|
182
|
+
|
|
183
|
+
<!-- A2A-50: Delete confirmation dialog for topics/goals -->
|
|
184
|
+
<sl-dialog id="delete-confirm-dialog" label="Confirm Removal" style="--width: 400px;">
|
|
185
|
+
<p id="delete-confirm-message">Remove this item from the tier?</p>
|
|
186
|
+
<div slot="footer" class="row">
|
|
187
|
+
<sl-button id="delete-confirm-no" variant="default">Cancel</sl-button>
|
|
188
|
+
<sl-button id="delete-confirm-yes" variant="danger">Remove</sl-button>
|
|
189
|
+
</div>
|
|
190
|
+
</sl-dialog>
|
|
191
|
+
|
|
192
|
+
<!-- A2A-50: New Tier dialog (glass-styled modal replacing inline form scroll) -->
|
|
193
|
+
<sl-dialog id="new-tier-dialog" label="Create New Tier" style="--width: 440px;">
|
|
194
|
+
<sl-input id="new-tier-dialog-id" label="Tier ID" placeholder="partners" required></sl-input>
|
|
195
|
+
<sl-input id="new-tier-dialog-name" label="Name" placeholder="Partners"></sl-input>
|
|
196
|
+
<label>Copy from
|
|
197
|
+
<sl-select id="new-tier-dialog-copy-from" size="small">
|
|
198
|
+
<sl-option value="">None</sl-option>
|
|
199
|
+
</sl-select>
|
|
200
|
+
</label>
|
|
201
|
+
<div slot="footer" class="row">
|
|
202
|
+
<sl-button id="new-tier-dialog-cancel" variant="default">Cancel</sl-button>
|
|
203
|
+
<sl-button id="new-tier-dialog-submit" variant="primary">Create Tier</sl-button>
|
|
204
|
+
</div>
|
|
205
|
+
</sl-dialog>
|
|
246
206
|
</div>
|
|
247
207
|
|
|
248
208
|
<div id="panel-invites" class="panel">
|
|
@@ -345,6 +305,77 @@
|
|
|
345
305
|
<tbody></tbody>
|
|
346
306
|
</table>
|
|
347
307
|
</div>
|
|
308
|
+
|
|
309
|
+
<!-- A2A-50: Settings panel — relocated from sl-details in panel-permissions -->
|
|
310
|
+
<div id="panel-settings" class="panel">
|
|
311
|
+
<h3>Defaults</h3>
|
|
312
|
+
<form id="defaults-form">
|
|
313
|
+
<sl-input id="defaults-expiration" label="Expiration" placeholder="7d"></sl-input>
|
|
314
|
+
<sl-input id="defaults-max-calls" label="Max Calls" type="number" min="1"></sl-input>
|
|
315
|
+
<div class="row">
|
|
316
|
+
<sl-button type="submit" variant="primary">Save Defaults</sl-button>
|
|
317
|
+
</div>
|
|
318
|
+
</form>
|
|
319
|
+
|
|
320
|
+
<h3>New Tier</h3>
|
|
321
|
+
<form id="new-tier-form">
|
|
322
|
+
<sl-input id="new-tier-id" label="Tier ID" placeholder="partners"></sl-input>
|
|
323
|
+
<sl-input id="new-tier-name" label="Name" placeholder="Partners"></sl-input>
|
|
324
|
+
<label>Copy from
|
|
325
|
+
<sl-select id="new-tier-copy-from" size="small">
|
|
326
|
+
<sl-option value="">None</sl-option>
|
|
327
|
+
</sl-select>
|
|
328
|
+
</label>
|
|
329
|
+
<div class="row">
|
|
330
|
+
<sl-button type="submit" variant="primary">Create Tier</sl-button>
|
|
331
|
+
</div>
|
|
332
|
+
</form>
|
|
333
|
+
|
|
334
|
+
<h3>Remote Callbook</h3>
|
|
335
|
+
<sl-card id="callbook-status"></sl-card>
|
|
336
|
+
|
|
337
|
+
<h3>Auto Update</h3>
|
|
338
|
+
<sl-card id="auto-update-status">Loading...</sl-card>
|
|
339
|
+
<div class="row">
|
|
340
|
+
<sl-button id="auto-update-check" size="small">Check now</sl-button>
|
|
341
|
+
<sl-button id="auto-update-now" size="small">Update now</sl-button>
|
|
342
|
+
<sl-button id="auto-update-toggle" size="small">Disable auto-update</sl-button>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<sl-card>
|
|
346
|
+
<form id="callbook-provision-form">
|
|
347
|
+
<div class="row">
|
|
348
|
+
<sl-button type="submit" variant="primary" size="small">Create Install Link (24h)</sl-button>
|
|
349
|
+
<sl-button id="callbook-logout" size="small" variant="default">Logout This Browser</sl-button>
|
|
350
|
+
</div>
|
|
351
|
+
<sl-input id="callbook-label" label="Device label" value="Callbook Remote"></sl-input>
|
|
352
|
+
<sl-textarea id="callbook-install-url" label="Install URL" rows="3" readonly></sl-textarea>
|
|
353
|
+
<div class="row">
|
|
354
|
+
<sl-button id="callbook-copy-url" size="small">Copy Link</sl-button>
|
|
355
|
+
</div>
|
|
356
|
+
<div id="callbook-warnings" class="mono"></div>
|
|
357
|
+
</form>
|
|
358
|
+
</sl-card>
|
|
359
|
+
|
|
360
|
+
<sl-card>
|
|
361
|
+
<div class="row">
|
|
362
|
+
<strong>Paired Devices</strong>
|
|
363
|
+
</div>
|
|
364
|
+
<table id="callbook-devices-table">
|
|
365
|
+
<thead>
|
|
366
|
+
<tr>
|
|
367
|
+
<th>Label</th>
|
|
368
|
+
<th>Created</th>
|
|
369
|
+
<th>Last Used</th>
|
|
370
|
+
<th>Sessions</th>
|
|
371
|
+
<th>Revoked</th>
|
|
372
|
+
<th>Action</th>
|
|
373
|
+
</tr>
|
|
374
|
+
</thead>
|
|
375
|
+
<tbody></tbody>
|
|
376
|
+
</table>
|
|
377
|
+
</sl-card>
|
|
378
|
+
</div>
|
|
348
379
|
</div>
|
|
349
380
|
</main>
|
|
350
381
|
|
|
@@ -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
|
}
|
|
@@ -1194,6 +1213,20 @@ sl-details::part(base) {
|
|
|
1194
1213
|
color: var(--ink);
|
|
1195
1214
|
}
|
|
1196
1215
|
|
|
1216
|
+
/* A2A-50: Glass styling for sl-dialog panels in the Permissions tab.
|
|
1217
|
+
Uses ::part(panel) to style the shadow DOM of Shoelace dialogs while
|
|
1218
|
+
preserving accessibility features (focus trap, ESC, ARIA). The codebase
|
|
1219
|
+
already uses ::part(base) on sl-card (line 205) and sl-details (line 1152). */
|
|
1220
|
+
#create-item-dialog::part(panel),
|
|
1221
|
+
#preview-dialog::part(panel),
|
|
1222
|
+
#delete-confirm-dialog::part(panel),
|
|
1223
|
+
#new-tier-dialog::part(panel) {
|
|
1224
|
+
background: rgba(30, 41, 59, 0.85);
|
|
1225
|
+
backdrop-filter: blur(16px);
|
|
1226
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
1227
|
+
border-radius: 16px;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1197
1230
|
/* ── A2A-48: Hide permissions sidebar below 1280px ─────────── */
|
|
1198
1231
|
@media (max-width: 1280px) {
|
|
1199
1232
|
.perm-sidebar {
|
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(),
|