@johnhenry/browsermesh-transport 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/package.json +31 -0
- package/src/channel-relay.mjs +225 -0
- package/src/cross-origin.mjs +543 -0
- package/src/gateway.mjs +627 -0
- package/src/index.mjs +12 -0
- package/src/relay.mjs +653 -0
- package/src/silent-catch.mjs +55 -0
- package/src/streams.mjs +627 -0
- package/src/transport.mjs +357 -0
- package/src/webrtc.mjs +773 -0
- package/src/websocket.mjs +1082 -0
- package/src/webtransport.mjs +216 -0
- package/src/wisp-client.mjs +747 -0
- package/src/wisp.mjs +348 -0
- package/src/wsh-bridge.mjs +242 -0
package/src/wisp.mjs
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* clawser-wisp-transport.mjs -- WISP Transport Adapter for WSH.
|
|
3
|
+
*
|
|
4
|
+
* Adapts a WispClient as a mesh transport that can carry WSH protocol
|
|
5
|
+
* messages through a WISP relay tunnel. WSH commands are serialized
|
|
6
|
+
* and sent over a dedicated control stream; additional streams can be
|
|
7
|
+
* opened for data transfer, RPC, or mesh relay connections.
|
|
8
|
+
*
|
|
9
|
+
* Implements the same interface as WebSocketTransport from
|
|
10
|
+
* clawser-mesh-websocket.js so it can be used interchangeably.
|
|
11
|
+
*
|
|
12
|
+
* Standalone uses beyond WSH:
|
|
13
|
+
* - Exposing RPC mode over a tunneled port
|
|
14
|
+
* - Mesh relay connections through restrictive networks
|
|
15
|
+
* - Future v86 guest networking
|
|
16
|
+
*
|
|
17
|
+
* Run tests:
|
|
18
|
+
* node --import ./web/test/_setup-globals.mjs --test web/test/clawser-wisp-transport.test.mjs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { WispClient, WispStream, WISP_DATA } from './wisp-client.mjs'
|
|
22
|
+
import { silentCatch } from './silent-catch.mjs'
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Constants
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** Default control stream port (WSH protocol) */
|
|
29
|
+
const WSH_CONTROL_PORT = 9083
|
|
30
|
+
|
|
31
|
+
/** Valid events */
|
|
32
|
+
const EVENTS = Object.freeze(['open', 'message', 'close', 'error', 'reconnect', 'stream'])
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// WispTransport
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* WISP-backed mesh transport.
|
|
40
|
+
*
|
|
41
|
+
* Wraps a WispClient to present the same interface as WebSocketTransport.
|
|
42
|
+
* A control stream carries WSH messages; additional streams can be opened
|
|
43
|
+
* for side-channel data (file transfers, RPC tunnels, etc.).
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* const transport = new WispTransport({
|
|
47
|
+
* url: 'wss://wisp-relay.example.com/',
|
|
48
|
+
* targetHost: 'my-server.local',
|
|
49
|
+
* targetPort: 9083,
|
|
50
|
+
* })
|
|
51
|
+
* await transport.connect()
|
|
52
|
+
* transport.send(wshMessage)
|
|
53
|
+
* transport.on('message', (data) => handleWshMessage(data))
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* // Open additional streams for data transfer
|
|
57
|
+
* const dataStream = transport.openStream('data-host.local', 8080)
|
|
58
|
+
* dataStream.write(payload)
|
|
59
|
+
*/
|
|
60
|
+
export class WispTransport {
|
|
61
|
+
/** @type {string} */
|
|
62
|
+
#url
|
|
63
|
+
|
|
64
|
+
/** @type {string} */
|
|
65
|
+
#targetHost
|
|
66
|
+
|
|
67
|
+
/** @type {number} */
|
|
68
|
+
#targetPort
|
|
69
|
+
|
|
70
|
+
/** @type {WispClient|null} */
|
|
71
|
+
#client = null
|
|
72
|
+
|
|
73
|
+
/** @type {WispStream|null} */
|
|
74
|
+
#controlStream = null
|
|
75
|
+
|
|
76
|
+
/** @type {string} */
|
|
77
|
+
#state = 'disconnected'
|
|
78
|
+
|
|
79
|
+
/** @type {boolean} */
|
|
80
|
+
#reconnect
|
|
81
|
+
|
|
82
|
+
/** @type {number} */
|
|
83
|
+
#maxReconnectAttempts
|
|
84
|
+
|
|
85
|
+
/** @type {number} */
|
|
86
|
+
#reconnectDelayMs
|
|
87
|
+
|
|
88
|
+
/** @type {Function} */
|
|
89
|
+
#WebSocketCtor
|
|
90
|
+
|
|
91
|
+
/** @type {{ open: Function[], message: Function[], close: Function[], error: Function[], reconnect: Function[], stream: Function[] }} */
|
|
92
|
+
#callbacks = { open: [], message: [], close: [], error: [], reconnect: [], stream: [] }
|
|
93
|
+
|
|
94
|
+
/** @type {{ messagesSent: number, messagesReceived: number, bytesIn: number, bytesOut: number, reconnects: number }} */
|
|
95
|
+
#stats = { messagesSent: 0, messagesReceived: 0, bytesIn: 0, bytesOut: 0, reconnects: 0 }
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {object} opts
|
|
99
|
+
* @param {string} opts.url - WISP relay WebSocket URL
|
|
100
|
+
* @param {string} [opts.targetHost='localhost'] - Target host for the WSH control stream
|
|
101
|
+
* @param {number} [opts.targetPort=9083] - Target port for the WSH control stream
|
|
102
|
+
* @param {boolean} [opts.reconnect=true] - Enable auto-reconnect
|
|
103
|
+
* @param {number} [opts.maxReconnectAttempts=5] - Max reconnection attempts
|
|
104
|
+
* @param {number} [opts.reconnectDelayMs=1000] - Base delay between reconnects
|
|
105
|
+
* @param {Function} [opts._WebSocket] - Injectable WebSocket constructor (for testing)
|
|
106
|
+
* @param {Function} [opts._WispClient] - Injectable WispClient constructor (for testing)
|
|
107
|
+
*/
|
|
108
|
+
constructor(opts = {}) {
|
|
109
|
+
if (!opts.url) throw new Error('url is required')
|
|
110
|
+
this.#url = opts.url
|
|
111
|
+
this.#targetHost = opts.targetHost || 'localhost'
|
|
112
|
+
this.#targetPort = opts.targetPort || WSH_CONTROL_PORT
|
|
113
|
+
this.#reconnect = opts.reconnect !== undefined ? opts.reconnect : true
|
|
114
|
+
this.#maxReconnectAttempts = opts.maxReconnectAttempts ?? 5
|
|
115
|
+
this.#reconnectDelayMs = opts.reconnectDelayMs ?? 1000
|
|
116
|
+
this.#WebSocketCtor = opts._WebSocket || globalThis.WebSocket
|
|
117
|
+
|
|
118
|
+
// Allow injecting a custom WispClient for testing
|
|
119
|
+
if (opts._WispClient) {
|
|
120
|
+
/** @type {Function} */
|
|
121
|
+
this._WispClientCtor = opts._WispClient
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// -- Getters ---------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/** Transport type identifier. */
|
|
128
|
+
get type() { return 'wisp' }
|
|
129
|
+
|
|
130
|
+
/** Current connection state. */
|
|
131
|
+
get state() { return this.#state }
|
|
132
|
+
|
|
133
|
+
/** True when transport is connected. */
|
|
134
|
+
get connected() { return this.#state === 'connected' }
|
|
135
|
+
|
|
136
|
+
/** WISP relay URL. */
|
|
137
|
+
get url() { return this.#url }
|
|
138
|
+
|
|
139
|
+
/** Target host for the control stream. */
|
|
140
|
+
get targetHost() { return this.#targetHost }
|
|
141
|
+
|
|
142
|
+
/** Target port for the control stream. */
|
|
143
|
+
get targetPort() { return this.#targetPort }
|
|
144
|
+
|
|
145
|
+
/** The underlying WispClient instance. */
|
|
146
|
+
get client() { return this.#client }
|
|
147
|
+
|
|
148
|
+
/** Whether auto-reconnect is enabled. */
|
|
149
|
+
get reconnectEnabled() { return this.#reconnect }
|
|
150
|
+
|
|
151
|
+
// -- Public API ------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Connect to the WISP relay and open the control stream.
|
|
155
|
+
* @returns {Promise<void>}
|
|
156
|
+
*/
|
|
157
|
+
async connect() {
|
|
158
|
+
if (this.#state === 'connected' || this.#state === 'connecting') {
|
|
159
|
+
throw new Error('Already connected or connecting')
|
|
160
|
+
}
|
|
161
|
+
this.#state = 'connecting'
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const ClientCtor = this._WispClientCtor || WispClient
|
|
165
|
+
this.#client = new ClientCtor({
|
|
166
|
+
url: this.#url,
|
|
167
|
+
reconnect: false, // we handle reconnection at this layer
|
|
168
|
+
_WebSocket: this.#WebSocketCtor,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
this.#client.on('error', (err) => this._fireEvent('error', err))
|
|
172
|
+
this.#client.on('close', () => this.#handleClientClose())
|
|
173
|
+
|
|
174
|
+
await this.#client.connect()
|
|
175
|
+
|
|
176
|
+
// open the WSH control stream
|
|
177
|
+
this.#controlStream = this.#client.open(this.#targetHost, this.#targetPort)
|
|
178
|
+
this.#controlStream.onData((data) => {
|
|
179
|
+
this.#stats.messagesReceived++
|
|
180
|
+
this.#stats.bytesIn += data.byteLength
|
|
181
|
+
this._fireEvent('message', data)
|
|
182
|
+
})
|
|
183
|
+
this.#controlStream.onClose((reason) => {
|
|
184
|
+
// control stream closed — treat as transport close
|
|
185
|
+
if (this.#state === 'connected') {
|
|
186
|
+
this.#handleClientClose()
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
this.#controlStream.onError((err) => this._fireEvent('error', err))
|
|
190
|
+
|
|
191
|
+
this.#state = 'connected'
|
|
192
|
+
this._fireEvent('open')
|
|
193
|
+
} catch (err) {
|
|
194
|
+
this.#state = 'disconnected'
|
|
195
|
+
this._fireEvent('error', err)
|
|
196
|
+
throw err
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Send data over the WSH control stream.
|
|
202
|
+
*
|
|
203
|
+
* @param {Uint8Array|ArrayBuffer|string} data
|
|
204
|
+
*/
|
|
205
|
+
send(data) {
|
|
206
|
+
if (!this.connected) throw new Error('Not connected')
|
|
207
|
+
if (!this.#controlStream || this.#controlStream.closed) {
|
|
208
|
+
throw new Error('Control stream is closed')
|
|
209
|
+
}
|
|
210
|
+
let bytes
|
|
211
|
+
if (typeof data === 'string') {
|
|
212
|
+
bytes = new TextEncoder().encode(data)
|
|
213
|
+
} else if (data instanceof ArrayBuffer) {
|
|
214
|
+
bytes = new Uint8Array(data)
|
|
215
|
+
} else {
|
|
216
|
+
bytes = data
|
|
217
|
+
}
|
|
218
|
+
this.#controlStream.write(bytes)
|
|
219
|
+
this.#stats.messagesSent++
|
|
220
|
+
this.#stats.bytesOut += bytes.byteLength
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Open an additional stream through the WISP relay.
|
|
225
|
+
* Useful for side-channel data: file transfers, RPC tunnels, etc.
|
|
226
|
+
*
|
|
227
|
+
* @example
|
|
228
|
+
* const rpcStream = transport.openStream('rpc-server.local', 5000)
|
|
229
|
+
*
|
|
230
|
+
* @param {string} host - Target host
|
|
231
|
+
* @param {number} port - Target port
|
|
232
|
+
* @returns {WispStream}
|
|
233
|
+
*/
|
|
234
|
+
openStream(host, port) {
|
|
235
|
+
if (!this.connected || !this.#client) throw new Error('Not connected')
|
|
236
|
+
const stream = this.#client.open(host, port)
|
|
237
|
+
this._fireEvent('stream', stream)
|
|
238
|
+
return stream
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Close the transport and underlying WISP client.
|
|
243
|
+
* @param {number} [code]
|
|
244
|
+
* @param {string} [reason]
|
|
245
|
+
* @returns {Promise<void>}
|
|
246
|
+
*/
|
|
247
|
+
async close(code, reason) {
|
|
248
|
+
if (this.#state === 'closed' || this.#state === 'disconnected') return
|
|
249
|
+
this.#state = 'closing'
|
|
250
|
+
|
|
251
|
+
if (this.#client) {
|
|
252
|
+
await this.#client.close()
|
|
253
|
+
}
|
|
254
|
+
this.#controlStream = null
|
|
255
|
+
this.#state = 'closed'
|
|
256
|
+
this._fireEvent('close')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Register an event listener.
|
|
261
|
+
* @param {string} event - One of: 'open', 'message', 'close', 'error', 'reconnect', 'stream'
|
|
262
|
+
* @param {Function} cb
|
|
263
|
+
*/
|
|
264
|
+
on(event, cb) {
|
|
265
|
+
if (!EVENTS.includes(event)) throw new Error(`Unknown event: ${event}`)
|
|
266
|
+
this.#callbacks[event].push(cb)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Get transport statistics.
|
|
271
|
+
* @returns {object}
|
|
272
|
+
*/
|
|
273
|
+
getStats() {
|
|
274
|
+
return { ...this.#stats }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Serialize to a JSON-safe object.
|
|
279
|
+
* @returns {object}
|
|
280
|
+
*/
|
|
281
|
+
toJSON() {
|
|
282
|
+
return {
|
|
283
|
+
type: this.type,
|
|
284
|
+
state: this.#state,
|
|
285
|
+
url: this.#url,
|
|
286
|
+
targetHost: this.#targetHost,
|
|
287
|
+
targetPort: this.#targetPort,
|
|
288
|
+
stats: this.getStats(),
|
|
289
|
+
clientInfo: this.#client ? this.#client.toJSON() : null,
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// -- Internal methods ------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Handle underlying client disconnect.
|
|
297
|
+
*/
|
|
298
|
+
#handleClientClose() {
|
|
299
|
+
if (this.#state === 'closing' || this.#state === 'closed') return
|
|
300
|
+
|
|
301
|
+
this.#controlStream = null
|
|
302
|
+
this.#state = 'disconnected'
|
|
303
|
+
this._fireEvent('close')
|
|
304
|
+
|
|
305
|
+
if (this.#reconnect) {
|
|
306
|
+
this.#attemptReconnect()
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** @type {number} */
|
|
311
|
+
#reconnectAttempts = 0
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Attempt reconnection with exponential backoff.
|
|
315
|
+
*/
|
|
316
|
+
async #attemptReconnect() {
|
|
317
|
+
if (this.#reconnectAttempts >= this.#maxReconnectAttempts) return
|
|
318
|
+
|
|
319
|
+
this.#reconnectAttempts++
|
|
320
|
+
this.#stats.reconnects++
|
|
321
|
+
this._fireEvent('reconnect', { attempt: this.#reconnectAttempts })
|
|
322
|
+
|
|
323
|
+
const delay = this.#reconnectDelayMs * Math.pow(2, this.#reconnectAttempts - 1)
|
|
324
|
+
await new Promise(r => setTimeout(r, delay))
|
|
325
|
+
|
|
326
|
+
if (this.#state === 'closing' || this.#state === 'closed') return
|
|
327
|
+
|
|
328
|
+
try {
|
|
329
|
+
await this.connect()
|
|
330
|
+
this.#reconnectAttempts = 0
|
|
331
|
+
} catch {
|
|
332
|
+
if (this.#reconnect && this.#reconnectAttempts < this.#maxReconnectAttempts) {
|
|
333
|
+
this.#attemptReconnect()
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Fire all callbacks for a given event.
|
|
340
|
+
* @param {string} event
|
|
341
|
+
* @param {*} [data]
|
|
342
|
+
*/
|
|
343
|
+
_fireEvent(event, data) {
|
|
344
|
+
for (const cb of this.#callbacks[event] || []) {
|
|
345
|
+
try { cb(data) } catch (e) { silentCatch('clawser-wisp-transport', 'swallow-listener-errors', e) }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
// STATUS: implemented + unit-tested (clawser-mesh-wsh-bridge.test.mjs), but
|
|
3
|
+
// never instantiated in app code — `import { MeshWshBridge }` in
|
|
4
|
+
// clawser-pod.js has no matching `new MeshWshBridge(...)` call site anywhere.
|
|
5
|
+
// Not a functional replacement for clawser-kernel-wsh-bridge.js despite the
|
|
6
|
+
// similar name and shared mention in docs/data/networking.yaml's "WSH Bridge
|
|
7
|
+
// Deprecation" entry — this bridges identity key *formats*, not wsh session
|
|
8
|
+
// tenant/capability routing.
|
|
9
|
+
* clawser-mesh-wsh-bridge.js -- Bridge between WshKeyStore and MeshIdentityManager.
|
|
10
|
+
*
|
|
11
|
+
* WshKeyStore uses hex-encoded SHA-256 fingerprints.
|
|
12
|
+
* MeshIdentityManager uses base64url-encoded SHA-256 pod IDs.
|
|
13
|
+
* Both hash the same raw Ed25519 public key bytes with SHA-256.
|
|
14
|
+
*
|
|
15
|
+
* This bridge converts between the two formats and syncs keys.
|
|
16
|
+
*
|
|
17
|
+
* Run tests:
|
|
18
|
+
* node --import ./web/test/_setup-globals.mjs --test web/test/clawser-mesh-wsh-bridge.test.mjs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
derivePodId,
|
|
23
|
+
encodeBase64url,
|
|
24
|
+
decodeBase64url,
|
|
25
|
+
} from '@johnhenry/browsermesh-primitives';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Hex <-> Base64url conversion helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert hex string to Uint8Array.
|
|
33
|
+
* @param {string} hex
|
|
34
|
+
* @returns {Uint8Array}
|
|
35
|
+
*/
|
|
36
|
+
function hexToBytes(hex) {
|
|
37
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
38
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
39
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
40
|
+
}
|
|
41
|
+
return bytes;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Convert Uint8Array to hex string.
|
|
46
|
+
* @param {Uint8Array} bytes
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
function bytesToHex(bytes) {
|
|
50
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// MeshWshBridge
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Bridges WshKeyStore (hex fingerprints) with MeshIdentityManager (base64url pod IDs).
|
|
59
|
+
*
|
|
60
|
+
* Both systems use SHA-256 of the raw Ed25519 public key as their identifier,
|
|
61
|
+
* but encode it differently: WshKeyStore uses lowercase hex, MeshIdentityManager
|
|
62
|
+
* uses base64url (no padding).
|
|
63
|
+
*/
|
|
64
|
+
export class MeshWshBridge {
|
|
65
|
+
/** @type {import('./packages/wsh/src/keystore.mjs').WshKeyStore} */
|
|
66
|
+
#wshKeyStore;
|
|
67
|
+
|
|
68
|
+
/** @type {import('./clawser-mesh-identity.js').MeshIdentityManager} */
|
|
69
|
+
#meshIdentityManager;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {*} wshKeyStore
|
|
73
|
+
* @param {*} meshIdentityManager
|
|
74
|
+
*/
|
|
75
|
+
constructor(wshKeyStore, meshIdentityManager) {
|
|
76
|
+
if (!wshKeyStore) throw new Error('wshKeyStore is required');
|
|
77
|
+
if (!meshIdentityManager) throw new Error('meshIdentityManager is required');
|
|
78
|
+
this.#wshKeyStore = wshKeyStore;
|
|
79
|
+
this.#meshIdentityManager = meshIdentityManager;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Convert a hex fingerprint to a base64url pod ID.
|
|
84
|
+
* Both are SHA-256 of the same raw public key, just different encodings.
|
|
85
|
+
* @param {string} hex - Hex-encoded SHA-256 fingerprint
|
|
86
|
+
* @returns {string} Base64url-encoded pod ID
|
|
87
|
+
*/
|
|
88
|
+
fingerprint2podId(hex) {
|
|
89
|
+
const bytes = hexToBytes(hex);
|
|
90
|
+
return encodeBase64url(bytes);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Convert a base64url pod ID to a hex fingerprint.
|
|
95
|
+
* @param {string} b64url - Base64url-encoded pod ID
|
|
96
|
+
* @returns {string} Hex-encoded fingerprint
|
|
97
|
+
*/
|
|
98
|
+
podId2fingerprint(b64url) {
|
|
99
|
+
const bytes = decodeBase64url(b64url);
|
|
100
|
+
return bytesToHex(bytes);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Import a key from WshKeyStore into MeshIdentityManager.
|
|
105
|
+
* @param {string} fingerprint - Hex fingerprint from WshKeyStore
|
|
106
|
+
* @returns {Promise<string>} The pod ID of the imported identity
|
|
107
|
+
*/
|
|
108
|
+
async importFromWsh(fingerprint) {
|
|
109
|
+
// Check if already imported
|
|
110
|
+
const podId = this.fingerprint2podId(fingerprint);
|
|
111
|
+
if (this.#meshIdentityManager.has(podId)) {
|
|
112
|
+
return podId;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Find the key in WshKeyStore by listing and matching fingerprint
|
|
116
|
+
const keys = await this.#wshKeyStore.listKeys();
|
|
117
|
+
const entry = keys.find(k => k.fingerprint === fingerprint);
|
|
118
|
+
if (!entry) {
|
|
119
|
+
throw new Error(`Key with fingerprint ${fingerprint} not found in WshKeyStore`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Get the full key pair
|
|
123
|
+
const keyPair = await this.#wshKeyStore.getKeyPair(entry.name);
|
|
124
|
+
|
|
125
|
+
// Export private key as JWK for mesh import
|
|
126
|
+
const jwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
|
127
|
+
|
|
128
|
+
const summary = await this.#meshIdentityManager.import(jwk, `wsh:${entry.name}`, {
|
|
129
|
+
metadata: { source: 'wsh', wshName: entry.name, wshFingerprint: fingerprint },
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return summary.podId;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Export an identity from MeshIdentityManager to WshKeyStore.
|
|
137
|
+
* @param {string} podId - Base64url pod ID
|
|
138
|
+
* @returns {Promise<string>} The hex fingerprint in WshKeyStore
|
|
139
|
+
*/
|
|
140
|
+
async exportToWsh(podId) {
|
|
141
|
+
const fp = this.podId2fingerprint(podId);
|
|
142
|
+
|
|
143
|
+
// Check if already in WshKeyStore
|
|
144
|
+
const keys = await this.#wshKeyStore.listKeys();
|
|
145
|
+
const existing = keys.find(k => k.fingerprint === fp);
|
|
146
|
+
if (existing) {
|
|
147
|
+
return fp;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Export from mesh as JWK
|
|
151
|
+
const jwk = await this.#meshIdentityManager.export(podId);
|
|
152
|
+
|
|
153
|
+
// Import into WshKeyStore
|
|
154
|
+
// WshKeyStore expects raw key import, but we have JWK
|
|
155
|
+
// We'll import via crypto.subtle first, then use WshKeyStore's generateKey pattern
|
|
156
|
+
const summary = this.#meshIdentityManager.get(podId);
|
|
157
|
+
const name = `mesh:${summary?.label || podId.slice(0, 8)}`;
|
|
158
|
+
|
|
159
|
+
// Import the private key as extractable
|
|
160
|
+
const privateKey = await crypto.subtle.importKey(
|
|
161
|
+
'jwk',
|
|
162
|
+
jwk,
|
|
163
|
+
{ name: 'Ed25519' },
|
|
164
|
+
true,
|
|
165
|
+
['sign']
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// Derive public key
|
|
169
|
+
const pubJwk = { ...jwk };
|
|
170
|
+
delete pubJwk.d;
|
|
171
|
+
pubJwk.key_ops = ['verify'];
|
|
172
|
+
const publicKey = await crypto.subtle.importKey(
|
|
173
|
+
'jwk',
|
|
174
|
+
pubJwk,
|
|
175
|
+
{ name: 'Ed25519' },
|
|
176
|
+
true,
|
|
177
|
+
['verify']
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
// Store directly in WshKeyStore's internal DB
|
|
181
|
+
// We need to use the _put method or store via the keystore's approach
|
|
182
|
+
// Since WshKeyStore doesn't have a direct import method, we'll use its internal _put
|
|
183
|
+
if (typeof this.#wshKeyStore._put === 'function') {
|
|
184
|
+
await this.#wshKeyStore._ensureDb();
|
|
185
|
+
await this.#wshKeyStore._put({
|
|
186
|
+
name,
|
|
187
|
+
publicKey,
|
|
188
|
+
privateKey,
|
|
189
|
+
createdAt: Date.now(),
|
|
190
|
+
fingerprint: fp,
|
|
191
|
+
});
|
|
192
|
+
} else {
|
|
193
|
+
throw new Error('WshKeyStore does not support direct key import');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return fp;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Sync all keys between both stores.
|
|
201
|
+
* Imports from WshKeyStore into Mesh that don't exist in Mesh,
|
|
202
|
+
* and exports from Mesh to WshKeyStore that don't exist in Wsh.
|
|
203
|
+
* @returns {Promise<{imported: number, exported: number}>}
|
|
204
|
+
*/
|
|
205
|
+
async syncAll() {
|
|
206
|
+
let imported = 0;
|
|
207
|
+
let exported = 0;
|
|
208
|
+
|
|
209
|
+
// Import from Wsh -> Mesh
|
|
210
|
+
const wshKeys = await this.#wshKeyStore.listKeys();
|
|
211
|
+
for (const key of wshKeys) {
|
|
212
|
+
const podId = this.fingerprint2podId(key.fingerprint);
|
|
213
|
+
if (!this.#meshIdentityManager.has(podId)) {
|
|
214
|
+
try {
|
|
215
|
+
await this.importFromWsh(key.fingerprint);
|
|
216
|
+
imported++;
|
|
217
|
+
} catch {
|
|
218
|
+
// Skip keys that fail to import (e.g., non-extractable)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Export from Mesh -> Wsh
|
|
224
|
+
const meshIds = this.#meshIdentityManager.list();
|
|
225
|
+
for (const id of meshIds) {
|
|
226
|
+
const fp = this.podId2fingerprint(id.podId);
|
|
227
|
+
const existing = wshKeys.find(k => k.fingerprint === fp);
|
|
228
|
+
if (!existing) {
|
|
229
|
+
try {
|
|
230
|
+
await this.exportToWsh(id.podId);
|
|
231
|
+
exported++;
|
|
232
|
+
} catch {
|
|
233
|
+
// Skip keys that fail to export
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { imported, exported };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export { hexToBytes, bytesToHex };
|