@highway1/core 0.1.43 → 0.1.45

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.
@@ -0,0 +1,55 @@
1
+ import { sign, verify } from './keys.js';
2
+ import { deriveDID } from './did.js';
3
+ import { IdentityError } from '../utils/errors.js';
4
+
5
+ export interface SignedMessage {
6
+ payload: Uint8Array;
7
+ signature: Uint8Array;
8
+ signer: string; // DID
9
+ }
10
+
11
+ /**
12
+ * Sign a message and return a signed message object
13
+ */
14
+ export async function signMessage(
15
+ payload: Uint8Array,
16
+ privateKey: Uint8Array,
17
+ publicKey: Uint8Array
18
+ ): Promise<SignedMessage> {
19
+ try {
20
+ const signature = await sign(payload, privateKey);
21
+ const signer = deriveDID(publicKey);
22
+
23
+ return {
24
+ payload,
25
+ signature,
26
+ signer,
27
+ };
28
+ } catch (error) {
29
+ throw new IdentityError('Failed to sign message', error);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Verify a signed message
35
+ */
36
+ export async function verifyMessage(
37
+ signedMessage: SignedMessage,
38
+ expectedPublicKey: Uint8Array
39
+ ): Promise<boolean> {
40
+ try {
41
+ const expectedDID = deriveDID(expectedPublicKey);
42
+
43
+ if (signedMessage.signer !== expectedDID) {
44
+ return false;
45
+ }
46
+
47
+ return await verify(
48
+ signedMessage.signature,
49
+ signedMessage.payload,
50
+ expectedPublicKey
51
+ );
52
+ } catch (error) {
53
+ throw new IdentityError('Failed to verify message', error);
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Clawiverse Core - Main Export
3
+ */
4
+
5
+ // Identity
6
+ export * from './identity/keys.js';
7
+ export * from './identity/did.js';
8
+ export * from './identity/signer.js';
9
+
10
+ // Transport
11
+ export * from './transport/node.js';
12
+
13
+ // Discovery
14
+ export * from './discovery/agent-card.js';
15
+ export * from './discovery/agent-card-types.js';
16
+ export * from './discovery/agent-card-schema.js';
17
+ export * from './discovery/agent-card-encoder.js';
18
+ export * from './discovery/dht.js';
19
+ export * from './discovery/search-index.js';
20
+ export * from './discovery/capability-matcher.js';
21
+ export * from './discovery/semantic-search.js';
22
+
23
+ // Messaging
24
+ export * from './messaging/envelope.js';
25
+ export * from './messaging/codec.js';
26
+ export * from './messaging/router.js';
27
+
28
+ // Trust (Phase 2)
29
+ export * from './trust/index.js';
30
+
31
+ // Utils
32
+ export * from './utils/logger.js';
33
+ export * from './utils/errors.js';
@@ -0,0 +1,47 @@
1
+ import { encode, decode } from 'cbor-x';
2
+ import type { MessageEnvelope } from './envelope.js';
3
+ import { MessagingError } from '../utils/errors.js';
4
+
5
+ /**
6
+ * Encode a message envelope to CBOR
7
+ */
8
+ export function encodeMessage(envelope: MessageEnvelope): Uint8Array {
9
+ try {
10
+ return encode(envelope);
11
+ } catch (error) {
12
+ throw new MessagingError('Failed to encode message', error);
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Decode a CBOR message to envelope
18
+ */
19
+ export function decodeMessage(data: Uint8Array): MessageEnvelope {
20
+ try {
21
+ return decode(data) as MessageEnvelope;
22
+ } catch (error) {
23
+ throw new MessagingError('Failed to decode message', error);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Encode message to JSON (for debugging/logging)
29
+ */
30
+ export function encodeMessageJSON(envelope: MessageEnvelope): string {
31
+ try {
32
+ return JSON.stringify(envelope, null, 2);
33
+ } catch (error) {
34
+ throw new MessagingError('Failed to encode message to JSON', error);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Decode JSON message to envelope
40
+ */
41
+ export function decodeMessageJSON(json: string): MessageEnvelope {
42
+ try {
43
+ return JSON.parse(json) as MessageEnvelope;
44
+ } catch (error) {
45
+ throw new MessagingError('Failed to decode message from JSON', error);
46
+ }
47
+ }
@@ -0,0 +1,107 @@
1
+ import { MessagingError } from '../utils/errors.js';
2
+
3
+ export interface MessageEnvelope {
4
+ id: string;
5
+ from: string; // DID
6
+ to: string; // DID
7
+ type: 'request' | 'response' | 'notification';
8
+ protocol: string;
9
+ payload: unknown;
10
+ timestamp: number;
11
+ signature: string;
12
+ replyTo?: string; // For responses, the ID of the request
13
+ }
14
+
15
+ /**
16
+ * Create a message envelope
17
+ */
18
+ export function createEnvelope(
19
+ from: string,
20
+ to: string,
21
+ type: 'request' | 'response' | 'notification',
22
+ protocol: string,
23
+ payload: unknown,
24
+ replyTo?: string
25
+ ): Omit<MessageEnvelope, 'signature'> {
26
+ return {
27
+ id: generateMessageId(),
28
+ from,
29
+ to,
30
+ type,
31
+ protocol,
32
+ payload,
33
+ timestamp: Date.now(),
34
+ replyTo,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Sign a message envelope
40
+ */
41
+ export async function signEnvelope(
42
+ envelope: Omit<MessageEnvelope, 'signature'>,
43
+ signFn: (data: Uint8Array) => Promise<Uint8Array>
44
+ ): Promise<MessageEnvelope> {
45
+ try {
46
+ const envelopeJson = JSON.stringify(envelope);
47
+ const envelopeBytes = new TextEncoder().encode(envelopeJson);
48
+ const signature = await signFn(envelopeBytes);
49
+
50
+ return {
51
+ ...envelope,
52
+ signature: Buffer.from(signature).toString('hex'),
53
+ };
54
+ } catch (error) {
55
+ throw new MessagingError('Failed to sign envelope', error);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Verify a message envelope signature
61
+ */
62
+ export async function verifyEnvelope(
63
+ envelope: MessageEnvelope,
64
+ verifyFn: (signature: Uint8Array, data: Uint8Array) => Promise<boolean>
65
+ ): Promise<boolean> {
66
+ try {
67
+ const { signature, ...envelopeWithoutSig } = envelope;
68
+ const envelopeJson = JSON.stringify(envelopeWithoutSig);
69
+ const envelopeBytes = new TextEncoder().encode(envelopeJson);
70
+ const signatureBytes = Buffer.from(signature, 'hex');
71
+
72
+ return await verifyFn(signatureBytes, envelopeBytes);
73
+ } catch (error) {
74
+ throw new MessagingError('Failed to verify envelope', error);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Validate message envelope structure
80
+ */
81
+ export function validateEnvelope(msg: unknown): msg is MessageEnvelope {
82
+ if (typeof msg !== 'object' || msg === null) {
83
+ return false;
84
+ }
85
+
86
+ const m = msg as Partial<MessageEnvelope>;
87
+
88
+ return (
89
+ typeof m.id === 'string' &&
90
+ typeof m.from === 'string' &&
91
+ m.from.startsWith('did:clawiverse:') &&
92
+ typeof m.to === 'string' &&
93
+ m.to.startsWith('did:clawiverse:') &&
94
+ (m.type === 'request' || m.type === 'response' || m.type === 'notification') &&
95
+ typeof m.protocol === 'string' &&
96
+ m.payload !== undefined &&
97
+ typeof m.timestamp === 'number' &&
98
+ typeof m.signature === 'string'
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Generate a unique message ID
104
+ */
105
+ function generateMessageId(): string {
106
+ return `msg_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`;
107
+ }
@@ -0,0 +1,3 @@
1
+ export * from './envelope.js';
2
+ export * from './codec.js';
3
+ export * from './router.js';
@@ -0,0 +1,384 @@
1
+ import type { Libp2p } from 'libp2p';
2
+ import { peerIdFromString } from '@libp2p/peer-id';
3
+ import { multiaddr } from '@multiformats/multiaddr';
4
+ import type { MessageEnvelope } from './envelope.js';
5
+ import type { DHTOperations } from '../discovery/dht.js';
6
+ import { encodeMessage, decodeMessage } from './codec.js';
7
+ import { validateEnvelope, verifyEnvelope } from './envelope.js';
8
+ import { createLogger } from '../utils/logger.js';
9
+ import { MessagingError } from '../utils/errors.js';
10
+ import { extractPublicKey } from '../identity/did.js';
11
+ import { verify } from '../identity/keys.js';
12
+
13
+ const logger = createLogger('router');
14
+
15
+ /** Helper to efficiently concatenate Uint8Array chunks */
16
+ function concatUint8Arrays(arrays: Uint8Array[]): Uint8Array {
17
+ if (arrays.length === 0) return new Uint8Array(0);
18
+ if (arrays.length === 1) return arrays[0];
19
+
20
+ const totalLength = arrays.reduce((acc, arr) => acc + arr.length, 0);
21
+ const result = new Uint8Array(totalLength);
22
+ let offset = 0;
23
+ for (const arr of arrays) {
24
+ result.set(arr, offset);
25
+ offset += arr.length;
26
+ }
27
+ return result;
28
+ }
29
+
30
+ export type MessageHandler = (
31
+ envelope: MessageEnvelope
32
+ ) => Promise<MessageEnvelope | void>;
33
+
34
+ export interface PeerHint {
35
+ peerId: string;
36
+ multiaddrs: string[];
37
+ }
38
+
39
+ export interface MessageRouter {
40
+ registerHandler: (protocol: string, handler: MessageHandler) => void;
41
+ unregisterHandler: (protocol: string) => void;
42
+ registerCatchAllHandler: (handler: MessageHandler) => void;
43
+ sendMessage: (envelope: MessageEnvelope, peerHint?: PeerHint) => Promise<MessageEnvelope | void>;
44
+ start: () => Promise<void>;
45
+ stop: () => Promise<void>;
46
+ }
47
+
48
+ /**
49
+ * Create a message router for a libp2p node.
50
+ * When dht is provided, sendMessage can resolve DIDs to peer addresses via DHT lookup.
51
+ * When relayPeers is provided, sendMessage will attempt relay fallback if direct dial fails.
52
+ */
53
+ export function createMessageRouter(
54
+ libp2p: Libp2p,
55
+ verifyFn: (signature: Uint8Array, data: Uint8Array) => Promise<boolean>,
56
+ dht?: DHTOperations,
57
+ relayPeers?: string[]
58
+ ): MessageRouter {
59
+ const handlers = new Map<string, MessageHandler>();
60
+ let catchAllHandler: MessageHandler | undefined;
61
+ const PROTOCOL_PREFIX = '/clawiverse/msg/1.0.0';
62
+
63
+ return {
64
+ registerHandler: (protocol: string, handler: MessageHandler) => {
65
+ handlers.set(protocol, handler);
66
+ logger.info('Registered message handler', { protocol });
67
+ },
68
+
69
+ unregisterHandler: (protocol: string) => {
70
+ handlers.delete(protocol);
71
+ logger.info('Unregistered message handler', { protocol });
72
+ },
73
+
74
+ registerCatchAllHandler: (handler: MessageHandler) => {
75
+ catchAllHandler = handler;
76
+ logger.info('Registered catch-all message handler');
77
+ },
78
+
79
+ sendMessage: async (envelope: MessageEnvelope, peerHint?: PeerHint) => {
80
+ try {
81
+ if (!validateEnvelope(envelope)) {
82
+ throw new MessagingError('Invalid message envelope');
83
+ }
84
+
85
+ // Resolve peer: try peerHint first, then DHT
86
+ let targetPeerIdStr: string | undefined;
87
+ let targetMultiaddrs: string[] = [];
88
+
89
+ if (peerHint) {
90
+ targetPeerIdStr = peerHint.peerId;
91
+ targetMultiaddrs = peerHint.multiaddrs;
92
+ logger.info('Using peer hint for direct addressing', { peerId: targetPeerIdStr });
93
+ } else if (dht) {
94
+ const resolved = await dht.resolveDID(envelope.to);
95
+ if (resolved) {
96
+ targetPeerIdStr = resolved.peerId;
97
+ targetMultiaddrs = resolved.multiaddrs;
98
+ }
99
+ }
100
+
101
+ if (!targetPeerIdStr) {
102
+ throw new MessagingError(
103
+ `Cannot resolve recipient: ${envelope.to} — provide peerHint or ensure agent is in DHT`
104
+ );
105
+ }
106
+
107
+ const targetPeerId = peerIdFromString(targetPeerIdStr);
108
+
109
+ if (targetMultiaddrs.length > 0) {
110
+ const mas = targetMultiaddrs.map((a) => multiaddr(a));
111
+ await libp2p.peerStore.merge(targetPeerId, { multiaddrs: mas });
112
+ }
113
+
114
+ logger.info('Dialing peer for message delivery', {
115
+ peerId: targetPeerIdStr,
116
+ multiaddrs: targetMultiaddrs,
117
+ });
118
+
119
+ let stream: any;
120
+ const DIAL_TIMEOUT = 3000; // 3 seconds per attempt
121
+
122
+ // Separate relay addrs from direct addrs
123
+ const relayMultiaddrs = targetMultiaddrs.filter((a) => a.includes('/p2p-circuit/'));
124
+ const directMultiaddrs = targetMultiaddrs.filter((a) => !a.includes('/p2p-circuit/'));
125
+
126
+ // Try direct addresses in parallel
127
+ if (directMultiaddrs.length > 0) {
128
+ const directDialPromises = directMultiaddrs.map(async (addr) => {
129
+ try {
130
+ const conn = await libp2p.dial(multiaddr(addr), {
131
+ signal: AbortSignal.timeout(DIAL_TIMEOUT)
132
+ });
133
+ const s = await conn.newStream(PROTOCOL_PREFIX, { runOnLimitedConnection: true });
134
+ logger.info('Direct dial succeeded', { addr });
135
+ return s;
136
+ } catch {
137
+ return null;
138
+ }
139
+ });
140
+
141
+ stream = await Promise.race(
142
+ directDialPromises.map(p => p.then(s => s || Promise.reject()))
143
+ ).catch(() => undefined);
144
+ }
145
+
146
+ let lastError: unknown;
147
+
148
+ // Fall back to relay: try all relay addresses in parallel
149
+ if (!stream) {
150
+ const allRelayAddrs = [
151
+ ...relayMultiaddrs,
152
+ ...(relayPeers ?? []).map((r) => buildCircuitRelayAddr(r, targetPeerIdStr!)),
153
+ ];
154
+ // Deduplicate
155
+ const uniqueRelayAddrs = [...new Set(allRelayAddrs)];
156
+
157
+ if (uniqueRelayAddrs.length > 0) {
158
+ const relayDialPromises = uniqueRelayAddrs.map(async (addr) => {
159
+ try {
160
+ const conn = await libp2p.dial(multiaddr(addr), {
161
+ signal: AbortSignal.timeout(DIAL_TIMEOUT)
162
+ });
163
+ logger.info('Relay connection established', { addr });
164
+ const s = await conn.newStream(PROTOCOL_PREFIX, { runOnLimitedConnection: true });
165
+ logger.info('Relay stream opened', { addr });
166
+ return s;
167
+ } catch (relayErr) {
168
+ logger.warn('Relay dial failed', { addr, error: (relayErr as Error).message });
169
+ lastError = relayErr;
170
+ return null;
171
+ }
172
+ });
173
+
174
+ stream = await Promise.race(
175
+ relayDialPromises.map(p => p.then(s => s || Promise.reject()))
176
+ ).catch(() => undefined);
177
+ }
178
+ }
179
+
180
+ // Last resort: query DHT for relay-capable peers
181
+ if (!stream && dht && 'queryRelayPeers' in dht) {
182
+ const discoveredRelays = await (dht as any).queryRelayPeers();
183
+ for (const relayAddr of discoveredRelays) {
184
+ const circuitAddr = buildCircuitRelayAddr(relayAddr, targetPeerIdStr!);
185
+ try {
186
+ const conn = await libp2p.dial(multiaddr(circuitAddr));
187
+ stream = await conn.newStream(PROTOCOL_PREFIX, { runOnLimitedConnection: true });
188
+ logger.info('DHT-discovered relay succeeded', { relayAddr });
189
+ break;
190
+ } catch (e) {
191
+ logger.warn('DHT relay failed', { relayAddr, error: (e as Error).message });
192
+ lastError = e;
193
+ }
194
+ }
195
+ }
196
+
197
+ if (!stream) {
198
+ throw lastError ?? new MessagingError('All dial attempts failed (including DHT-discovered relays)');
199
+ }
200
+
201
+ const encoded = encodeMessage(envelope);
202
+ await stream.sink(
203
+ (async function* () {
204
+ yield encoded;
205
+ })()
206
+ );
207
+
208
+ logger.info('Message sent over libp2p stream', {
209
+ id: envelope.id,
210
+ from: envelope.from,
211
+ to: envelope.to,
212
+ protocol: envelope.protocol,
213
+ });
214
+
215
+ // If this is a request, wait for response
216
+ if (envelope.type === 'request') {
217
+ logger.debug('Waiting for response to request', { id: envelope.id });
218
+
219
+ try {
220
+ // Add timeout to prevent infinite blocking
221
+ const RESPONSE_TIMEOUT = 5000; // 5 seconds
222
+
223
+ const responsePromise = (async () => {
224
+ const responseChunks: Uint8Array[] = [];
225
+ for await (const chunk of stream.source) {
226
+ responseChunks.push(chunk.subarray());
227
+ }
228
+
229
+ if (responseChunks.length > 0) {
230
+ const responseData = concatUint8Arrays(responseChunks);
231
+ const responseEnvelope = decodeMessage(responseData);
232
+ logger.info('Received response', {
233
+ id: responseEnvelope.id,
234
+ replyTo: responseEnvelope.replyTo,
235
+ });
236
+
237
+ return responseEnvelope;
238
+ } else {
239
+ logger.warn('No response received for request', { id: envelope.id });
240
+ return undefined;
241
+ }
242
+ })();
243
+
244
+ const timeoutPromise = new Promise<undefined>((resolve) => {
245
+ setTimeout(() => {
246
+ logger.warn('Response timeout', { id: envelope.id, timeout: RESPONSE_TIMEOUT });
247
+ resolve(undefined);
248
+ }, RESPONSE_TIMEOUT);
249
+ });
250
+
251
+ const response = await Promise.race([responsePromise, timeoutPromise]);
252
+ return response;
253
+ } catch (error) {
254
+ logger.warn('Error reading response', { error });
255
+ return undefined;
256
+ }
257
+ }
258
+
259
+ return undefined;
260
+ } catch (error) {
261
+ if (error instanceof MessagingError) throw error;
262
+ throw new MessagingError('Failed to send message', error);
263
+ }
264
+ },
265
+
266
+ start: async () => {
267
+ await libp2p.handle(PROTOCOL_PREFIX, async ({ stream }) => {
268
+ try {
269
+ await handleIncomingStream(stream, handlers, catchAllHandler, verifyFn);
270
+ } catch (error) {
271
+ logger.error('Error handling incoming stream', error);
272
+ }
273
+ }, { runOnLimitedConnection: true });
274
+
275
+ logger.info('Message router started');
276
+ },
277
+
278
+ stop: async () => {
279
+ await libp2p.unhandle(PROTOCOL_PREFIX);
280
+ handlers.clear();
281
+ catchAllHandler = undefined;
282
+ logger.info('Message router stopped');
283
+ },
284
+ };
285
+ }
286
+
287
+ function buildCircuitRelayAddr(relayAddr: string, targetPeerId: string): string {
288
+ return `${relayAddr}/p2p-circuit/p2p/${targetPeerId}`;
289
+ }
290
+
291
+ async function handleIncomingStream(
292
+ stream: any,
293
+ handlers: Map<string, MessageHandler>,
294
+ catchAllHandler: MessageHandler | undefined,
295
+ verifyFn: (signature: Uint8Array, data: Uint8Array) => Promise<boolean>
296
+ ): Promise<void> {
297
+ try {
298
+ const chunks: Uint8Array[] = [];
299
+ for await (const chunk of stream.source) {
300
+ chunks.push(chunk.subarray());
301
+ }
302
+
303
+ const data = concatUint8Arrays(chunks);
304
+ const envelope = decodeMessage(data);
305
+
306
+ if (!validateEnvelope(envelope)) {
307
+ logger.warn('Received invalid message envelope');
308
+ return;
309
+ }
310
+
311
+ // Verify signature against sender DID embedded public key.
312
+ const isValidSignature = await verifyEnvelope(envelope, async (signature, data) => {
313
+ const senderPublicKey = extractPublicKey(envelope.from);
314
+ return verify(signature, data, senderPublicKey);
315
+ });
316
+ if (!isValidSignature) {
317
+ logger.warn('Received message with invalid signature', {
318
+ id: envelope.id,
319
+ from: envelope.from,
320
+ });
321
+ return;
322
+ }
323
+
324
+ // Optional caller-supplied verification hook (e.g. policy checks).
325
+ try {
326
+ const { signature, ...envelopeWithoutSig } = envelope;
327
+ const dataBytes = new TextEncoder().encode(JSON.stringify(envelopeWithoutSig));
328
+ const signatureBytes = Buffer.from(signature, 'hex');
329
+ const hookValid = await verifyFn(signatureBytes, dataBytes);
330
+ if (!hookValid) {
331
+ logger.warn('Message rejected by custom verifier', {
332
+ id: envelope.id,
333
+ from: envelope.from,
334
+ });
335
+ return;
336
+ }
337
+ } catch (error) {
338
+ logger.warn('Custom verification hook failed', {
339
+ id: envelope.id,
340
+ from: envelope.from,
341
+ error: (error as Error).message,
342
+ });
343
+ return;
344
+ }
345
+
346
+ logger.info('Received message', {
347
+ id: envelope.id,
348
+ from: envelope.from,
349
+ to: envelope.to,
350
+ protocol: envelope.protocol,
351
+ payload: envelope.payload,
352
+ });
353
+
354
+ const handler = handlers.get(envelope.protocol);
355
+ let response: MessageEnvelope | void = undefined;
356
+
357
+ if (handler) {
358
+ response = await handler(envelope);
359
+ } else if (catchAllHandler) {
360
+ logger.debug('Using catch-all handler for protocol', { protocol: envelope.protocol });
361
+ response = await catchAllHandler(envelope);
362
+ } else {
363
+ logger.warn('No handler for protocol', { protocol: envelope.protocol });
364
+ }
365
+
366
+ // Send response back if handler returned one
367
+ if (response) {
368
+ const encoded = encodeMessage(response);
369
+ logger.info('Sending response back to sender', {
370
+ responseId: response.id,
371
+ replyTo: response.replyTo,
372
+ size: encoded.length
373
+ });
374
+
375
+ await stream.sink(
376
+ (async function* () {
377
+ yield encoded;
378
+ })()
379
+ );
380
+ }
381
+ } catch (error) {
382
+ logger.error('Error handling incoming message', error);
383
+ }
384
+ }