@serfab/cadre-core 0.0.2
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/README.md +252 -0
- package/dist/arachnode-stub.d.ts +60 -0
- package/dist/arachnode-stub.js +120 -0
- package/dist/arachnode-stub.js.map +1 -0
- package/dist/cadre-node.d.ts +252 -0
- package/dist/cadre-node.js +753 -0
- package/dist/cadre-node.js.map +1 -0
- package/dist/control-database.d.ts +62 -0
- package/dist/control-database.js +295 -0
- package/dist/control-database.js.map +1 -0
- package/dist/enrollment.d.ts +76 -0
- package/dist/enrollment.js +126 -0
- package/dist/enrollment.js.map +1 -0
- package/dist/hibernation-manager.d.ts +68 -0
- package/dist/hibernation-manager.js +212 -0
- package/dist/hibernation-manager.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +164 -0
- package/dist/seed-bootstrap.js +554 -0
- package/dist/seed-bootstrap.js.map +1 -0
- package/dist/strand-database.d.ts +39 -0
- package/dist/strand-database.js +108 -0
- package/dist/strand-database.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +87 -0
- package/dist/strand-formation-manager.js +168 -0
- package/dist/strand-formation-manager.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +60 -0
- package/dist/strand-instance-manager.js +209 -0
- package/dist/strand-instance-manager.js.map +1 -0
- package/dist/strand-solicitation.d.ts +145 -0
- package/dist/strand-solicitation.js +180 -0
- package/dist/strand-solicitation.js.map +1 -0
- package/dist/strand-watcher.d.ts +70 -0
- package/dist/strand-watcher.js +136 -0
- package/dist/strand-watcher.js.map +1 -0
- package/dist/types.d.ts +484 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/package.json +67 -0
- package/src/arachnode-stub.ts +160 -0
- package/src/cadre-node.ts +923 -0
- package/src/control-database.ts +376 -0
- package/src/enrollment.ts +199 -0
- package/src/hibernation-manager.ts +268 -0
- package/src/index.ts +71 -0
- package/src/seed-bootstrap.ts +753 -0
- package/src/strand-database.ts +168 -0
- package/src/strand-formation-manager.ts +277 -0
- package/src/strand-instance-manager.ts +281 -0
- package/src/strand-solicitation.ts +318 -0
- package/src/strand-watcher.ts +197 -0
- package/src/types.ts +553 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
3
|
+
import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
|
|
4
|
+
import type { Libp2p, PeerId, Connection } from '@libp2p/interface';
|
|
5
|
+
import { multiaddr } from '@multiformats/multiaddr';
|
|
6
|
+
import type { Multiaddr } from '@multiformats/multiaddr';
|
|
7
|
+
import { peerIdFromString } from '@libp2p/peer-id';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Minimal libp2p stream interface for cross-platform compatibility.
|
|
11
|
+
* This abstraction works across different libp2p versions and environments.
|
|
12
|
+
*/
|
|
13
|
+
interface LibP2PStream {
|
|
14
|
+
source: AsyncIterable<Uint8Array>;
|
|
15
|
+
sink(source: AsyncIterable<Uint8Array>): Promise<void>;
|
|
16
|
+
close(): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Incoming stream data from libp2p handle callback.
|
|
21
|
+
* Defined locally for cross-version compatibility.
|
|
22
|
+
*/
|
|
23
|
+
interface IncomingStreamData {
|
|
24
|
+
stream: LibP2PStream;
|
|
25
|
+
connection: Connection;
|
|
26
|
+
}
|
|
27
|
+
import type {
|
|
28
|
+
ControlNetworkSeed,
|
|
29
|
+
SeedPeer,
|
|
30
|
+
SeedMessage,
|
|
31
|
+
SeedAckMessage,
|
|
32
|
+
AuthorizePeerOptions,
|
|
33
|
+
ApplySeedResult,
|
|
34
|
+
SignedTransaction,
|
|
35
|
+
AddDroneOptions,
|
|
36
|
+
AddPhoneOptions,
|
|
37
|
+
DroneInitResult,
|
|
38
|
+
InviteResult,
|
|
39
|
+
CadreInvite
|
|
40
|
+
} from './types.js';
|
|
41
|
+
import type { ControlDatabase } from './control-database.js';
|
|
42
|
+
|
|
43
|
+
const log = debug('sereus:cadre:seed-bootstrap');
|
|
44
|
+
|
|
45
|
+
/** Protocol ID for seed delivery */
|
|
46
|
+
export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
|
|
47
|
+
|
|
48
|
+
/** Maximum seed message size (1MB) */
|
|
49
|
+
const MAX_SEED_SIZE = 1024 * 1024;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Configuration for the SeedBootstrapService
|
|
53
|
+
*/
|
|
54
|
+
export interface SeedBootstrapConfig {
|
|
55
|
+
/** Party ID for this cadre */
|
|
56
|
+
partyId: string;
|
|
57
|
+
/** Authority private key for signing seeds and peer authorizations (base64url) */
|
|
58
|
+
authorityPrivateKey?: string;
|
|
59
|
+
/** Authority public key (base64url) - derived from private key if not provided */
|
|
60
|
+
authorityPublicKey?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Event callbacks for seed-related events
|
|
65
|
+
*/
|
|
66
|
+
export interface SeedEventCallbacks {
|
|
67
|
+
/** Called when a seed is received via the protocol */
|
|
68
|
+
onSeedReceived?: (partyId: string, peerId: string) => void;
|
|
69
|
+
/** Called when a seed is successfully applied */
|
|
70
|
+
onSeedApplied?: (partyId: string, peersAdded: number) => void;
|
|
71
|
+
/** Called when seed application fails */
|
|
72
|
+
onSeedError?: (partyId: string, error: string) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* SeedBootstrapService handles control network seed generation and delivery.
|
|
77
|
+
*
|
|
78
|
+
* Seeds solve the cold-start problem: new nodes need control data to validate
|
|
79
|
+
* connections, but can't get data without connecting first. Seeds pre-populate
|
|
80
|
+
* the new node's cache with peer information and optionally transactions.
|
|
81
|
+
*/
|
|
82
|
+
export class SeedBootstrapService {
|
|
83
|
+
private readonly config: SeedBootstrapConfig;
|
|
84
|
+
private libp2pNode: Libp2p | null = null;
|
|
85
|
+
private controlDatabase: ControlDatabase | null = null;
|
|
86
|
+
private readonly authorityPublicKey: string | null;
|
|
87
|
+
private eventCallbacks: SeedEventCallbacks = {};
|
|
88
|
+
|
|
89
|
+
constructor(config: SeedBootstrapConfig) {
|
|
90
|
+
this.config = config;
|
|
91
|
+
|
|
92
|
+
// Derive public key from private key if not provided
|
|
93
|
+
if (config.authorityPrivateKey && !config.authorityPublicKey) {
|
|
94
|
+
this.authorityPublicKey = getPublicKey(
|
|
95
|
+
config.authorityPrivateKey,
|
|
96
|
+
'ed25519',
|
|
97
|
+
'base64url',
|
|
98
|
+
'base64url'
|
|
99
|
+
) as string;
|
|
100
|
+
} else {
|
|
101
|
+
this.authorityPublicKey = config.authorityPublicKey ?? null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
log('SeedBootstrapService created for party: %s', config.partyId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Set event callbacks for seed-related events.
|
|
109
|
+
* Used by CadreNode to emit events.
|
|
110
|
+
*/
|
|
111
|
+
setEventCallbacks(callbacks: SeedEventCallbacks): void {
|
|
112
|
+
this.eventCallbacks = callbacks;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Initialize the service with libp2p node and control database
|
|
117
|
+
*/
|
|
118
|
+
initialize(libp2pNode: Libp2p, controlDatabase: ControlDatabase): void {
|
|
119
|
+
this.libp2pNode = libp2pNode;
|
|
120
|
+
this.controlDatabase = controlDatabase;
|
|
121
|
+
|
|
122
|
+
// Register the seed protocol handler
|
|
123
|
+
this.registerProtocolHandler();
|
|
124
|
+
|
|
125
|
+
log('SeedBootstrapService initialized');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Authorize a new peer to join the cadre.
|
|
130
|
+
* Signs the peer ID with the authority key and inserts into CadrePeer table.
|
|
131
|
+
*/
|
|
132
|
+
async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
|
|
133
|
+
const { peerId, multiaddrs } = options;
|
|
134
|
+
|
|
135
|
+
if (!this.config.authorityPrivateKey) {
|
|
136
|
+
throw new Error('Authority private key required to authorize peers');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!this.controlDatabase) {
|
|
140
|
+
throw new Error('Control database not initialized');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
log('Authorizing peer: %s', peerId);
|
|
144
|
+
|
|
145
|
+
// Sign the peer ID with the authority key
|
|
146
|
+
const peerIdDigest = digest(peerId, 'sha256', 'utf8', 'base64url') as string;
|
|
147
|
+
const signature = sign(
|
|
148
|
+
peerIdDigest,
|
|
149
|
+
this.config.authorityPrivateKey,
|
|
150
|
+
'ed25519',
|
|
151
|
+
'base64url',
|
|
152
|
+
'base64url',
|
|
153
|
+
'base64url'
|
|
154
|
+
) as string;
|
|
155
|
+
|
|
156
|
+
// Insert into CadrePeer table with authority context
|
|
157
|
+
const db = this.controlDatabase.getDatabase();
|
|
158
|
+
const multiaddrStr = multiaddrs?.join(',') ?? null;
|
|
159
|
+
const multiaddrValue = multiaddrStr ? `'${multiaddrStr}'` : 'null';
|
|
160
|
+
|
|
161
|
+
await db.exec(`
|
|
162
|
+
insert into CadreControl.CadrePeer (PeerId, Multiaddr)
|
|
163
|
+
with context AuthorityKey = '${this.authorityPublicKey}', Signature = '${signature}'
|
|
164
|
+
values ('${peerId}', ${multiaddrValue})
|
|
165
|
+
`);
|
|
166
|
+
|
|
167
|
+
log('Peer %s authorized successfully', peerId);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Create a seed from the current control network state.
|
|
172
|
+
* The seed contains peer information and is signed by an authority.
|
|
173
|
+
*/
|
|
174
|
+
async createSeed(): Promise<ControlNetworkSeed> {
|
|
175
|
+
if (!this.config.authorityPrivateKey || !this.authorityPublicKey) {
|
|
176
|
+
throw new Error('Authority key required to create seeds');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (!this.controlDatabase || !this.libp2pNode) {
|
|
180
|
+
throw new Error('Service not initialized');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
log('Creating seed for party: %s', this.config.partyId);
|
|
184
|
+
|
|
185
|
+
// Query all peers from the control database
|
|
186
|
+
const peers = await this.queryPeers();
|
|
187
|
+
|
|
188
|
+
// Create the seed data (without signature)
|
|
189
|
+
const seedData = {
|
|
190
|
+
partyId: this.config.partyId,
|
|
191
|
+
peers,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Sign the seed
|
|
195
|
+
const seedJson = JSON.stringify(seedData);
|
|
196
|
+
const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url') as string;
|
|
197
|
+
const signature = sign(
|
|
198
|
+
seedDigest,
|
|
199
|
+
this.config.authorityPrivateKey,
|
|
200
|
+
'ed25519',
|
|
201
|
+
'base64url',
|
|
202
|
+
'base64url',
|
|
203
|
+
'base64url'
|
|
204
|
+
) as string;
|
|
205
|
+
|
|
206
|
+
const seed: ControlNetworkSeed = {
|
|
207
|
+
...seedData,
|
|
208
|
+
signature,
|
|
209
|
+
signerKey: this.authorityPublicKey,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
log('Created seed with %d peers', peers.length);
|
|
213
|
+
return seed;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Apply a seed to populate the peer cache and enable connections.
|
|
218
|
+
* Validates the seed signature before applying.
|
|
219
|
+
*/
|
|
220
|
+
async applySeed(seed: ControlNetworkSeed): Promise<ApplySeedResult> {
|
|
221
|
+
if (!this.libp2pNode) {
|
|
222
|
+
return { success: false, peersAdded: 0, error: 'Service not initialized' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
log('Applying seed for party: %s', seed.partyId);
|
|
226
|
+
|
|
227
|
+
// Validate the seed signature
|
|
228
|
+
if (!this.validateSeedSignature(seed)) {
|
|
229
|
+
return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Verify the signer is an authority in the seed's peer list
|
|
233
|
+
const signerPeer = seed.peers.find(p => p.isAuthority);
|
|
234
|
+
if (!signerPeer) {
|
|
235
|
+
return { success: false, peersAdded: 0, error: 'No authority peer in seed' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let peersAdded = 0;
|
|
239
|
+
|
|
240
|
+
// Add peers to the peer store
|
|
241
|
+
for (const peer of seed.peers) {
|
|
242
|
+
try {
|
|
243
|
+
// Import peer multiaddrs into the peer store
|
|
244
|
+
if (peer.multiaddrs.length > 0) {
|
|
245
|
+
const peerId = peerIdFromString(peer.peerId);
|
|
246
|
+
const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
|
|
247
|
+
|
|
248
|
+
await this.libp2pNode.peerStore.merge(peerId, {
|
|
249
|
+
multiaddrs: addrs
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
peersAdded++;
|
|
253
|
+
log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
log('Failed to add peer %s: %o', peer.peerId, error);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Dial authority peers to establish connections
|
|
261
|
+
for (const peer of seed.peers.filter(p => p.isAuthority)) {
|
|
262
|
+
try {
|
|
263
|
+
if (peer.multiaddrs.length > 0) {
|
|
264
|
+
const addr = multiaddr(peer.multiaddrs[0]);
|
|
265
|
+
|
|
266
|
+
log('Dialing authority peer: %s', peer.peerId);
|
|
267
|
+
await this.libp2pNode.dial(addr);
|
|
268
|
+
}
|
|
269
|
+
} catch (error) {
|
|
270
|
+
log('Failed to dial peer %s: %o', peer.peerId, error);
|
|
271
|
+
// Continue - not all peers need to be reachable
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
log('Applied seed: %d peers added', peersAdded);
|
|
276
|
+
return { success: true, peersAdded };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
|
|
281
|
+
*/
|
|
282
|
+
encodeSeed(seed: ControlNetworkSeed): string {
|
|
283
|
+
const json = JSON.stringify(seed);
|
|
284
|
+
return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Decode a seed from base64url encoding.
|
|
289
|
+
*/
|
|
290
|
+
decodeSeed(encoded: string): ControlNetworkSeed {
|
|
291
|
+
const bytes = uint8ArrayFromString(encoded, 'base64url');
|
|
292
|
+
const json = new TextDecoder().decode(bytes);
|
|
293
|
+
return JSON.parse(json) as ControlNetworkSeed;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
298
|
+
*/
|
|
299
|
+
async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<SeedAckMessage> {
|
|
300
|
+
if (!this.libp2pNode) {
|
|
301
|
+
throw new Error('Service not initialized');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const addr = multiaddr(targetMultiaddr);
|
|
305
|
+
|
|
306
|
+
log('Delivering seed to: %s', targetMultiaddr);
|
|
307
|
+
|
|
308
|
+
// Dial the target and open a stream
|
|
309
|
+
const rawStream = await this.libp2pNode.dialProtocol(addr, SEED_PROTOCOL);
|
|
310
|
+
const stream = rawStream as unknown as LibP2PStream;
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
// Send the seed message
|
|
314
|
+
const message: SeedMessage = {
|
|
315
|
+
partyId: seed.partyId,
|
|
316
|
+
peers: seed.peers,
|
|
317
|
+
transactions: seed.transactions,
|
|
318
|
+
signature: seed.signature,
|
|
319
|
+
signerKey: seed.signerKey,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const messageBytes = new TextEncoder().encode(JSON.stringify(message));
|
|
323
|
+
|
|
324
|
+
// Write length-prefixed message
|
|
325
|
+
const lengthBytes = new Uint8Array(4);
|
|
326
|
+
new DataView(lengthBytes.buffer).setUint32(0, messageBytes.length, false);
|
|
327
|
+
|
|
328
|
+
async function* sinkData() { yield lengthBytes; yield messageBytes; }
|
|
329
|
+
await stream.sink(sinkData());
|
|
330
|
+
|
|
331
|
+
// Read the acknowledgment
|
|
332
|
+
const chunks: Uint8Array[] = [];
|
|
333
|
+
for await (const chunk of stream.source) {
|
|
334
|
+
chunks.push((chunk as Uint8Array).subarray());
|
|
335
|
+
// Read until we have the length prefix
|
|
336
|
+
if (chunks.reduce((sum, c) => sum + c.length, 0) >= 4) break;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const responseData = new Uint8Array(chunks.reduce((sum, c) => sum + c.length, 0));
|
|
340
|
+
let offset = 0;
|
|
341
|
+
for (const chunk of chunks) {
|
|
342
|
+
responseData.set(chunk, offset);
|
|
343
|
+
offset += chunk.length;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Parse length and read remaining data if needed
|
|
347
|
+
const responseLength = new DataView(responseData.buffer).getUint32(0, false);
|
|
348
|
+
const responseJson = new TextDecoder().decode(responseData.slice(4, 4 + responseLength));
|
|
349
|
+
const ack = JSON.parse(responseJson) as SeedAckMessage;
|
|
350
|
+
|
|
351
|
+
log('Seed delivery response: accepted=%s', ack.accepted);
|
|
352
|
+
return ack;
|
|
353
|
+
|
|
354
|
+
} finally {
|
|
355
|
+
await stream.close();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Get this node's circuit relay address for inclusion in seeds.
|
|
361
|
+
* Returns null if no relay address is available.
|
|
362
|
+
*/
|
|
363
|
+
async getRelayAddress(): Promise<string | null> {
|
|
364
|
+
if (!this.libp2pNode) {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const addrs = this.libp2pNode.getMultiaddrs();
|
|
369
|
+
|
|
370
|
+
// Find a circuit relay address
|
|
371
|
+
const relayAddr = addrs.find(addr => addr.toString().includes('/p2p-circuit/'));
|
|
372
|
+
|
|
373
|
+
return relayAddr?.toString() ?? null;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Validate a seed's signature.
|
|
378
|
+
*/
|
|
379
|
+
validateSeedSignature(seed: ControlNetworkSeed): boolean {
|
|
380
|
+
try {
|
|
381
|
+
// Reconstruct the signed data (seed without signature fields)
|
|
382
|
+
const seedData = {
|
|
383
|
+
partyId: seed.partyId,
|
|
384
|
+
peers: seed.peers,
|
|
385
|
+
...(seed.transactions ? { transactions: seed.transactions } : {}),
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const seedJson = JSON.stringify(seedData);
|
|
389
|
+
const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url') as string;
|
|
390
|
+
|
|
391
|
+
return verify(
|
|
392
|
+
seedDigest,
|
|
393
|
+
seed.signature,
|
|
394
|
+
seed.signerKey,
|
|
395
|
+
'ed25519',
|
|
396
|
+
'base64url',
|
|
397
|
+
'base64url',
|
|
398
|
+
'base64url'
|
|
399
|
+
);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
log('Seed signature validation failed: %o', error);
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Query peers from the control database.
|
|
408
|
+
*/
|
|
409
|
+
private async queryPeers(): Promise<SeedPeer[]> {
|
|
410
|
+
if (!this.controlDatabase) {
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const db = this.controlDatabase.getDatabase();
|
|
415
|
+
const peers: SeedPeer[] = [];
|
|
416
|
+
|
|
417
|
+
// First, get all authority keys
|
|
418
|
+
const authorityKeys = new Set<string>();
|
|
419
|
+
for await (const row of db.eval('select Key from CadreControl.AuthorityKey')) {
|
|
420
|
+
authorityKeys.add(row.Key as string);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Query CadrePeer table
|
|
424
|
+
for await (const row of db.eval('select PeerId, Multiaddr from CadreControl.CadrePeer')) {
|
|
425
|
+
const peerId = row.PeerId as string;
|
|
426
|
+
const multiaddr = row.Multiaddr as string | null;
|
|
427
|
+
|
|
428
|
+
// Check if this peer's public key is an authority
|
|
429
|
+
// Note: Authority keys are base64url public keys, not peer IDs
|
|
430
|
+
// For now, we mark the peer as authority if it matches the service's authority key
|
|
431
|
+
const isAuthority = peerId === this.libp2pNode?.peerId.toString();
|
|
432
|
+
|
|
433
|
+
peers.push({
|
|
434
|
+
peerId,
|
|
435
|
+
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
436
|
+
isAuthority,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
return peers;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Register the seed protocol handler.
|
|
445
|
+
*/
|
|
446
|
+
private registerProtocolHandler(): void {
|
|
447
|
+
if (!this.libp2pNode) return;
|
|
448
|
+
|
|
449
|
+
this.libp2pNode.handle(SEED_PROTOCOL, async (data: unknown) => {
|
|
450
|
+
const { stream, connection } = data as IncomingStreamData;
|
|
451
|
+
const remotePeerId = connection.remotePeer.toString();
|
|
452
|
+
log('Incoming seed delivery from: %s', remotePeerId);
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
// Read the seed message
|
|
456
|
+
const chunks: Uint8Array[] = [];
|
|
457
|
+
let totalLength = 0;
|
|
458
|
+
|
|
459
|
+
for await (const chunk of stream.source) {
|
|
460
|
+
chunks.push((chunk as Uint8Array).subarray());
|
|
461
|
+
totalLength += (chunk as Uint8Array).length;
|
|
462
|
+
if (totalLength > MAX_SEED_SIZE) {
|
|
463
|
+
throw new Error('Seed message too large');
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const data = new Uint8Array(totalLength);
|
|
468
|
+
let offset = 0;
|
|
469
|
+
for (const chunk of chunks) {
|
|
470
|
+
data.set(chunk, offset);
|
|
471
|
+
offset += chunk.length;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Parse length-prefixed message
|
|
475
|
+
const messageLength = new DataView(data.buffer).getUint32(0, false);
|
|
476
|
+
const messageJson = new TextDecoder().decode(data.slice(4, 4 + messageLength));
|
|
477
|
+
const message = JSON.parse(messageJson) as SeedMessage;
|
|
478
|
+
|
|
479
|
+
// Emit seed received event
|
|
480
|
+
this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
|
|
481
|
+
|
|
482
|
+
// Convert to seed and apply
|
|
483
|
+
const seed: ControlNetworkSeed = {
|
|
484
|
+
partyId: message.partyId,
|
|
485
|
+
peers: message.peers,
|
|
486
|
+
transactions: message.transactions,
|
|
487
|
+
signature: message.signature,
|
|
488
|
+
signerKey: message.signerKey,
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
const result = await this.applySeed(seed);
|
|
492
|
+
|
|
493
|
+
// Emit appropriate event based on result
|
|
494
|
+
if (result.success) {
|
|
495
|
+
this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
|
|
496
|
+
} else {
|
|
497
|
+
this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Send acknowledgment
|
|
501
|
+
const ack: SeedAckMessage = {
|
|
502
|
+
accepted: result.success,
|
|
503
|
+
reason: result.error,
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
|
|
507
|
+
const lengthBytes = new Uint8Array(4);
|
|
508
|
+
new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
|
|
509
|
+
|
|
510
|
+
async function* sinkAck() { yield lengthBytes; yield ackBytes; }
|
|
511
|
+
await stream.sink(sinkAck());
|
|
512
|
+
|
|
513
|
+
} catch (error) {
|
|
514
|
+
log('Error handling seed delivery: %o', error);
|
|
515
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
516
|
+
|
|
517
|
+
// Emit error event
|
|
518
|
+
this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
|
|
519
|
+
|
|
520
|
+
// Send error acknowledgment
|
|
521
|
+
const ack: SeedAckMessage = {
|
|
522
|
+
accepted: false,
|
|
523
|
+
reason: errorMessage,
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
|
|
527
|
+
const lengthBytes = new Uint8Array(4);
|
|
528
|
+
new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
async function* sinkError() { yield lengthBytes; yield ackBytes; }
|
|
532
|
+
await stream.sink(sinkError());
|
|
533
|
+
} catch {
|
|
534
|
+
// Ignore sink errors
|
|
535
|
+
}
|
|
536
|
+
} finally {
|
|
537
|
+
await stream.close();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
log('Registered seed protocol handler: %s', SEED_PROTOCOL);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Shutdown the service.
|
|
546
|
+
*/
|
|
547
|
+
async shutdown(): Promise<void> {
|
|
548
|
+
if (this.libp2pNode) {
|
|
549
|
+
await this.libp2pNode.unhandle(SEED_PROTOCOL);
|
|
550
|
+
}
|
|
551
|
+
this.libp2pNode = null;
|
|
552
|
+
this.controlDatabase = null;
|
|
553
|
+
log('SeedBootstrapService shutdown');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ============================================================================
|
|
557
|
+
// Helper Functions for Common Scenarios
|
|
558
|
+
// ============================================================================
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Add a drone to the cadre (phone/server adds provider-hosted node).
|
|
562
|
+
*
|
|
563
|
+
* Use this when you've spawned a drone via provider API and received its
|
|
564
|
+
* peer ID and multiaddrs. This method:
|
|
565
|
+
* 1. Authorizes the drone peer
|
|
566
|
+
* 2. Creates a seed including all current peers
|
|
567
|
+
* 3. Returns the seed for sending to provider API
|
|
568
|
+
*
|
|
569
|
+
* @param options - Drone peer info from provider API
|
|
570
|
+
* @returns Seed and encoded seed for drone initialization
|
|
571
|
+
*/
|
|
572
|
+
async addDrone(options: AddDroneOptions): Promise<DroneInitResult> {
|
|
573
|
+
const { dronePeerId, droneMultiaddrs } = options;
|
|
574
|
+
|
|
575
|
+
log('Adding drone: %s', dronePeerId);
|
|
576
|
+
|
|
577
|
+
// 1. Authorize the new drone peer
|
|
578
|
+
await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs });
|
|
579
|
+
|
|
580
|
+
// 2. Create seed with current state
|
|
581
|
+
const seed = await this.createSeed();
|
|
582
|
+
|
|
583
|
+
// 3. Encode for transport
|
|
584
|
+
const encodedSeed = this.encodeSeed(seed);
|
|
585
|
+
|
|
586
|
+
log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
|
|
587
|
+
|
|
588
|
+
return { seed, encodedSeed };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Create an invite for a phone to join the cadre.
|
|
593
|
+
*
|
|
594
|
+
* Use this when a server (public IP) wants to invite a phone (NAT'd).
|
|
595
|
+
* The invite is shared out-of-band (QR code, link, etc.) and contains
|
|
596
|
+
* the server's address so the phone can dial in.
|
|
597
|
+
*
|
|
598
|
+
* @param token - Optional invite token for validation
|
|
599
|
+
* @param expiresIn - Optional expiration time in milliseconds
|
|
600
|
+
* @returns Invite and encoded invite for sharing
|
|
601
|
+
*/
|
|
602
|
+
async createInvite(token?: string, expiresIn?: number): Promise<InviteResult> {
|
|
603
|
+
if (!this.libp2pNode) {
|
|
604
|
+
throw new Error('Service not initialized');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
log('Creating invite for phone');
|
|
608
|
+
|
|
609
|
+
// Get this node's dialable addresses
|
|
610
|
+
const addrs = this.libp2pNode.getMultiaddrs();
|
|
611
|
+
const authorityAddrs = addrs.map(a => a.toString());
|
|
612
|
+
|
|
613
|
+
const now = Date.now();
|
|
614
|
+
const invite: CadreInvite = {
|
|
615
|
+
partyId: this.config.partyId,
|
|
616
|
+
authorityAddrs,
|
|
617
|
+
token,
|
|
618
|
+
createdAt: now,
|
|
619
|
+
expiresAt: expiresIn ? now + expiresIn : undefined,
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const encodedInvite = this.encodeInvite(invite);
|
|
623
|
+
|
|
624
|
+
log('Invite created with %d authority addresses', authorityAddrs.length);
|
|
625
|
+
|
|
626
|
+
return { invite, encodedInvite };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Accept a phone connection using an invite.
|
|
631
|
+
*
|
|
632
|
+
* Use this when a phone dials in with an invite token. This method:
|
|
633
|
+
* 1. Validates the token if provided
|
|
634
|
+
* 2. Authorizes the phone peer
|
|
635
|
+
*
|
|
636
|
+
* After this, the phone can sync the control database normally.
|
|
637
|
+
*
|
|
638
|
+
* @param options - Phone peer info and invite token
|
|
639
|
+
* @param issuedInvite - The original invite for validation
|
|
640
|
+
*/
|
|
641
|
+
async acceptPhone(options: AddPhoneOptions, issuedInvite?: CadreInvite): Promise<void> {
|
|
642
|
+
const { phonePeerId, token } = options;
|
|
643
|
+
|
|
644
|
+
log('Accepting phone: %s', phonePeerId);
|
|
645
|
+
|
|
646
|
+
// Validate token if invite provided
|
|
647
|
+
if (issuedInvite) {
|
|
648
|
+
if (issuedInvite.token && issuedInvite.token !== token) {
|
|
649
|
+
throw new Error('Invalid invite token');
|
|
650
|
+
}
|
|
651
|
+
if (issuedInvite.expiresAt && Date.now() > issuedInvite.expiresAt) {
|
|
652
|
+
throw new Error('Invite has expired');
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Authorize the phone peer (no multiaddrs - phone is NAT'd)
|
|
657
|
+
await this.authorizePeer({ peerId: phonePeerId });
|
|
658
|
+
|
|
659
|
+
log('Phone %s accepted and authorized', phonePeerId);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Add a phone to the cadre with relay support.
|
|
664
|
+
*
|
|
665
|
+
* Use this when both nodes are NAT'd (phone-to-phone). This method:
|
|
666
|
+
* 1. Authorizes the new phone peer
|
|
667
|
+
* 2. Creates a seed with relay addresses for dialing
|
|
668
|
+
*
|
|
669
|
+
* @param phonePeerId - Peer ID of the new phone
|
|
670
|
+
* @returns Seed with relay addresses for out-of-band delivery
|
|
671
|
+
*/
|
|
672
|
+
async addPhoneWithRelay(phonePeerId: string): Promise<DroneInitResult> {
|
|
673
|
+
log('Adding phone with relay: %s', phonePeerId);
|
|
674
|
+
|
|
675
|
+
// 1. Authorize the new phone peer (no multiaddrs - NAT'd)
|
|
676
|
+
await this.authorizePeer({ peerId: phonePeerId });
|
|
677
|
+
|
|
678
|
+
// 2. Get relay address for this node
|
|
679
|
+
const relayAddr = await this.getRelayAddress();
|
|
680
|
+
|
|
681
|
+
// 3. Create seed - will include our relay address if available
|
|
682
|
+
const seed = await this.createSeed();
|
|
683
|
+
|
|
684
|
+
// If we have a relay address, make sure it's in our peer entry
|
|
685
|
+
if (relayAddr && this.libp2pNode) {
|
|
686
|
+
const ourPeerId = this.libp2pNode.peerId.toString();
|
|
687
|
+
const ourPeer = seed.peers.find(p => p.peerId === ourPeerId);
|
|
688
|
+
if (ourPeer && !ourPeer.multiaddrs.includes(relayAddr)) {
|
|
689
|
+
ourPeer.multiaddrs.push(relayAddr);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const encodedSeed = this.encodeSeed(seed);
|
|
694
|
+
|
|
695
|
+
log('Phone %s added with relay, seed created', phonePeerId);
|
|
696
|
+
|
|
697
|
+
return { seed, encodedSeed };
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Encode an invite for out-of-band delivery.
|
|
702
|
+
*/
|
|
703
|
+
encodeInvite(invite: CadreInvite): string {
|
|
704
|
+
const json = JSON.stringify(invite);
|
|
705
|
+
return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Decode an invite from base64url encoding.
|
|
710
|
+
*/
|
|
711
|
+
decodeInvite(encoded: string): CadreInvite {
|
|
712
|
+
const bytes = uint8ArrayFromString(encoded, 'base64url');
|
|
713
|
+
const json = new TextDecoder().decode(bytes);
|
|
714
|
+
return JSON.parse(json) as CadreInvite;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Dial an authority from an invite.
|
|
719
|
+
* Use this on a phone after receiving an invite to connect to the authority.
|
|
720
|
+
*
|
|
721
|
+
* @param invite - The invite received out-of-band
|
|
722
|
+
* @returns Connection to the authority
|
|
723
|
+
*/
|
|
724
|
+
async dialInvite(invite: CadreInvite): Promise<void> {
|
|
725
|
+
if (!this.libp2pNode) {
|
|
726
|
+
throw new Error('Service not initialized');
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Check expiration
|
|
730
|
+
if (invite.expiresAt && Date.now() > invite.expiresAt) {
|
|
731
|
+
throw new Error('Invite has expired');
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
log('Dialing invite authority with %d addresses', invite.authorityAddrs.length);
|
|
735
|
+
|
|
736
|
+
// Try each authority address until one succeeds
|
|
737
|
+
let lastError: Error | null = null;
|
|
738
|
+
for (const addrStr of invite.authorityAddrs) {
|
|
739
|
+
try {
|
|
740
|
+
const addr = multiaddr(addrStr);
|
|
741
|
+
await this.libp2pNode.dial(addr);
|
|
742
|
+
log('Connected to authority at: %s', addrStr);
|
|
743
|
+
return;
|
|
744
|
+
} catch (error) {
|
|
745
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
746
|
+
log('Failed to dial %s: %o', addrStr, error);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
throw lastError ?? new Error('No authority addresses available');
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|