@serfab/cadre-core 0.7.1 → 0.8.1
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 +9 -8
- package/dist/authority-key.d.ts +41 -0
- package/dist/authority-key.js +52 -0
- package/dist/authority-key.js.map +1 -0
- package/dist/cadre-node.d.ts +770 -11
- package/dist/cadre-node.js +1900 -88
- package/dist/cadre-node.js.map +1 -1
- package/dist/canonical-datetime.d.ts +33 -0
- package/dist/canonical-datetime.js +38 -0
- package/dist/canonical-datetime.js.map +1 -0
- package/dist/canonical-json.d.ts +12 -0
- package/dist/canonical-json.js +33 -0
- package/dist/canonical-json.js.map +1 -0
- package/dist/control-cohort.d.ts +63 -0
- package/dist/control-cohort.js +80 -0
- package/dist/control-cohort.js.map +1 -0
- package/dist/control-database.d.ts +259 -5
- package/dist/control-database.js +541 -128
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +89 -0
- package/dist/control-formation-recorder.js +139 -0
- package/dist/control-formation-recorder.js.map +1 -0
- package/dist/control-schema.d.ts +12 -0
- package/dist/control-schema.js +199 -0
- package/dist/control-schema.js.map +1 -0
- package/dist/control-stream.d.ts +54 -0
- package/dist/control-stream.js +104 -0
- package/dist/control-stream.js.map +1 -0
- package/dist/device-token.d.ts +50 -0
- package/dist/device-token.js +66 -0
- package/dist/device-token.js.map +1 -0
- package/dist/diagnostics/connection-path.d.ts +118 -0
- package/dist/diagnostics/connection-path.js +204 -0
- package/dist/diagnostics/connection-path.js.map +1 -0
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
- package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
- package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
- package/dist/hibernation-manager.d.ts +79 -0
- package/dist/hibernation-manager.js +173 -23
- package/dist/hibernation-manager.js.map +1 -1
- package/dist/index.d.ts +25 -4
- package/dist/index.js +59 -4
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +52 -0
- package/dist/key-store-file.js +194 -0
- package/dist/key-store-file.js.map +1 -0
- package/dist/key-store.d.ts +75 -0
- package/dist/key-store.js +66 -0
- package/dist/key-store.js.map +1 -0
- package/dist/peer-authorization.d.ts +26 -0
- package/dist/peer-authorization.js +40 -0
- package/dist/peer-authorization.js.map +1 -0
- package/dist/peer-record.d.ts +89 -0
- package/dist/peer-record.js +117 -0
- package/dist/peer-record.js.map +1 -0
- package/dist/push-credentials.d.ts +34 -0
- package/dist/push-credentials.js +81 -0
- package/dist/push-credentials.js.map +1 -0
- package/dist/push-fanout.d.ts +137 -0
- package/dist/push-fanout.js +197 -0
- package/dist/push-fanout.js.map +1 -0
- package/dist/push-notifier-apns.d.ts +53 -0
- package/dist/push-notifier-apns.js +195 -0
- package/dist/push-notifier-apns.js.map +1 -0
- package/dist/push-notifier-fcm.d.ts +38 -0
- package/dist/push-notifier-fcm.js +183 -0
- package/dist/push-notifier-fcm.js.map +1 -0
- package/dist/push-notifier-shared.d.ts +18 -0
- package/dist/push-notifier-shared.js +25 -0
- package/dist/push-notifier-shared.js.map +1 -0
- package/dist/push-notifier.d.ts +71 -0
- package/dist/push-notifier.js +50 -0
- package/dist/push-notifier.js.map +1 -0
- package/dist/schema-verification.d.ts +11 -2
- package/dist/schema-verification.js +15 -4
- package/dist/schema-verification.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +197 -9
- package/dist/seed-bootstrap.js +396 -137
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +63 -0
- package/dist/seed-trust-policy.js +56 -0
- package/dist/seed-trust-policy.js.map +1 -0
- package/dist/strand-addr-protocol.d.ts +132 -0
- package/dist/strand-addr-protocol.js +313 -0
- package/dist/strand-addr-protocol.js.map +1 -0
- package/dist/strand-cohort.d.ts +54 -0
- package/dist/strand-cohort.js +38 -0
- package/dist/strand-cohort.js.map +1 -0
- package/dist/strand-database.d.ts +54 -6
- package/dist/strand-database.js +88 -100
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +79 -19
- package/dist/strand-formation-manager.js +196 -104
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +162 -0
- package/dist/strand-formation-protocol.js +279 -0
- package/dist/strand-formation-protocol.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +73 -4
- package/dist/strand-instance-manager.js +167 -41
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +29 -0
- package/dist/strand-member-key.js +38 -0
- package/dist/strand-member-key.js.map +1 -0
- package/dist/strand-member-registry.d.ts +98 -0
- package/dist/strand-member-registry.js +129 -0
- package/dist/strand-member-registry.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +361 -0
- package/dist/strand-membership-writer.js +449 -0
- package/dist/strand-membership-writer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +77 -2
- package/dist/strand-solicitation.js +25 -4
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-payload.d.ts +30 -0
- package/dist/strand-wake-payload.js +20 -0
- package/dist/strand-wake-payload.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +114 -0
- package/dist/strand-wake-protocol.js +257 -0
- package/dist/strand-wake-protocol.js.map +1 -0
- package/dist/strand-watcher.d.ts +6 -2
- package/dist/strand-watcher.js +57 -17
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +406 -23
- package/dist/types.js +22 -5
- package/dist/types.js.map +1 -1
- package/package.json +14 -8
- package/src/authority-key.ts +68 -0
- package/src/cadre-node.ts +2937 -948
- package/src/canonical-datetime.ts +39 -0
- package/src/canonical-json.ts +30 -0
- package/src/control-cohort.ts +103 -0
- package/src/control-database.ts +931 -383
- package/src/control-formation-recorder.ts +151 -0
- package/src/control-schema.ts +198 -0
- package/src/control-stream.ts +128 -0
- package/src/device-token.ts +89 -0
- package/src/diagnostics/connection-path.ts +277 -0
- package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
- package/src/hibernation-manager.ts +197 -28
- package/src/index.ts +273 -79
- package/src/key-store-file.ts +203 -0
- package/src/key-store.ts +99 -0
- package/src/peer-authorization.ts +53 -0
- package/src/peer-record.ts +146 -0
- package/src/push-credentials.ts +84 -0
- package/src/push-fanout.ts +272 -0
- package/src/push-notifier-apns.ts +243 -0
- package/src/push-notifier-fcm.ts +221 -0
- package/src/push-notifier-shared.ts +28 -0
- package/src/push-notifier.ts +93 -0
- package/src/schema-verification.ts +18 -4
- package/src/seed-bootstrap.ts +464 -188
- package/src/seed-trust-policy.ts +107 -0
- package/src/strand-addr-protocol.ts +410 -0
- package/src/strand-cohort.ts +77 -0
- package/src/strand-database.ts +113 -141
- package/src/strand-formation-manager.ts +233 -142
- package/src/strand-formation-protocol.ts +391 -0
- package/src/strand-instance-manager.ts +207 -56
- package/src/strand-member-key.ts +39 -0
- package/src/strand-member-registry.ts +177 -0
- package/src/strand-membership-writer.ts +640 -0
- package/src/strand-solicitation.ts +93 -4
- package/src/strand-wake-payload.ts +32 -0
- package/src/strand-wake-protocol.ts +331 -0
- package/src/strand-watcher.ts +61 -17
- package/src/types.ts +1012 -582
package/src/seed-bootstrap.ts
CHANGED
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
3
3
|
import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
|
|
4
|
-
import type { Libp2p,
|
|
4
|
+
import type { Libp2p, Connection } from '@libp2p/interface';
|
|
5
5
|
import { multiaddr } from '@multiformats/multiaddr';
|
|
6
|
-
import type { Multiaddr } from '@multiformats/multiaddr';
|
|
7
6
|
import { peerIdFromString } from '@libp2p/peer-id';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Minimal libp2p stream surface compatible with libp2p 3.x.
|
|
11
|
-
* In libp2p 3.x, streams are AsyncIterable for reading and use send() for writing.
|
|
12
|
-
*/
|
|
13
|
-
interface LibP2PStream extends AsyncIterable<Uint8Array> {
|
|
14
|
-
send(data: Uint8Array): boolean;
|
|
15
|
-
close(): Promise<void>;
|
|
16
|
-
abort(err: Error): void;
|
|
17
|
-
}
|
|
7
|
+
import { type ControlStream, writeFrame, readStreamToEnd } from './control-stream.js';
|
|
18
8
|
import type {
|
|
19
9
|
ControlNetworkSeed,
|
|
20
10
|
SeedPeer,
|
|
@@ -22,14 +12,21 @@ import type {
|
|
|
22
12
|
SeedAckMessage,
|
|
23
13
|
AuthorizePeerOptions,
|
|
24
14
|
ApplySeedResult,
|
|
25
|
-
SignedTransaction,
|
|
26
15
|
AddDroneOptions,
|
|
27
16
|
AddPhoneOptions,
|
|
28
17
|
DroneInitResult,
|
|
29
18
|
InviteResult,
|
|
30
|
-
CadreInvite
|
|
19
|
+
CadreInvite,
|
|
20
|
+
PeerAddressRecord,
|
|
21
|
+
DeviceTokenRecord
|
|
31
22
|
} from './types.js';
|
|
32
23
|
import type { ControlDatabase } from './control-database.js';
|
|
24
|
+
import { canonicalJson } from './canonical-json.js';
|
|
25
|
+
import { peerAuthorizationDigest } from './peer-authorization.js';
|
|
26
|
+
import {
|
|
27
|
+
type SeedTrustPolicy,
|
|
28
|
+
dbAnchoredTrustPolicy,
|
|
29
|
+
} from './seed-trust-policy.js';
|
|
33
30
|
|
|
34
31
|
const log = debug('sereus:cadre:seed-bootstrap');
|
|
35
32
|
|
|
@@ -39,6 +36,75 @@ export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
|
|
|
39
36
|
/** Maximum seed message size (1MB) */
|
|
40
37
|
const MAX_SEED_SIZE = 1024 * 1024;
|
|
41
38
|
|
|
39
|
+
/** Default time the receiver waits for an inbound seed frame before aborting (ms). */
|
|
40
|
+
const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
|
|
41
|
+
|
|
42
|
+
/** Default cap on concurrent inbound seed streams a single peer can pin open. */
|
|
43
|
+
const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
|
|
47
|
+
*
|
|
48
|
+
* Guards every parse site against malformed input: a buffer too short to hold
|
|
49
|
+
* the prefix, a declared length exceeding `maxLength`, and a declared length
|
|
50
|
+
* exceeding the bytes actually present. Returns a view (`subarray`, no copy) —
|
|
51
|
+
* the body is handed straight to `TextDecoder`.
|
|
52
|
+
*/
|
|
53
|
+
export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED_SIZE): Uint8Array {
|
|
54
|
+
if (data.length < 4) {
|
|
55
|
+
throw new Error(`Seed frame too short: ${data.length} bytes, need ≥4 for length prefix`);
|
|
56
|
+
}
|
|
57
|
+
// Pass the full (buffer, byteOffset, byteLength) triple so the read is correct
|
|
58
|
+
// even for a non-zero-offset view, not just the fresh zero-offset arrays
|
|
59
|
+
// current callers pass.
|
|
60
|
+
const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0, false);
|
|
61
|
+
const available = data.length - 4;
|
|
62
|
+
if (length > maxLength) {
|
|
63
|
+
throw new Error(`Seed frame declares length ${length} exceeding max ${maxLength}`);
|
|
64
|
+
}
|
|
65
|
+
if (length > available) {
|
|
66
|
+
throw new Error(`Seed frame declares length ${length} but only ${available} body bytes present`);
|
|
67
|
+
}
|
|
68
|
+
return data.subarray(4, 4 + length);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
|
|
73
|
+
*
|
|
74
|
+
* An Ed25519 PeerId is an identity multihash of the public key, so
|
|
75
|
+
* `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
|
|
76
|
+
* base64url form matches the `AuthorityKey.Key` representation (and
|
|
77
|
+
* `authorityKeyFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
|
|
78
|
+
* id, a missing embedded key, or any parse failure — callers treat null as
|
|
79
|
+
* "not an authority" rather than throwing.
|
|
80
|
+
*/
|
|
81
|
+
export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = peerIdFromString(peerId);
|
|
84
|
+
if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Canonical byte representation of the authenticated seed fields.
|
|
95
|
+
*
|
|
96
|
+
* Routes both the creator (`createSeed`) and the verifier
|
|
97
|
+
* (`validateSeedSignature`) through one builder so the signed bytes are
|
|
98
|
+
* identical regardless of key insertion order. `canonicalJson` sorts keys and
|
|
99
|
+
* drops `undefined`, so the signed payload is exactly `{ partyId, peers }` —
|
|
100
|
+
* the fields the producer actually emits.
|
|
101
|
+
*/
|
|
102
|
+
export function canonicalSeedPayload(
|
|
103
|
+
seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>
|
|
104
|
+
): string {
|
|
105
|
+
return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
|
|
106
|
+
}
|
|
107
|
+
|
|
42
108
|
/**
|
|
43
109
|
* Configuration for the SeedBootstrapService
|
|
44
110
|
*/
|
|
@@ -56,6 +122,30 @@ export interface SeedBootstrapConfig {
|
|
|
56
122
|
* DDNS hostname and externally-mapped port.
|
|
57
123
|
*/
|
|
58
124
|
inviteAddressResolver?: () => Promise<string[]>;
|
|
125
|
+
/**
|
|
126
|
+
* Trust anchor for incoming seeds. Decides whether a signature-verified
|
|
127
|
+
* `signerKey` should be trusted, against the receiver's known authority
|
|
128
|
+
* keys (NOT the seed body). Defaults to `dbAnchoredTrustPolicy()`, which
|
|
129
|
+
* rejects any signer not already in the `AuthorityKey` table. An enrollment
|
|
130
|
+
* caller can pass a per-seed override to `applySeed` instead.
|
|
131
|
+
*
|
|
132
|
+
* A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
|
|
133
|
+
* — that is the only seam the inbound libp2p seed-protocol handler can use,
|
|
134
|
+
* since a network-delivered seed has no per-call override.
|
|
135
|
+
*/
|
|
136
|
+
trustPolicy?: SeedTrustPolicy;
|
|
137
|
+
/**
|
|
138
|
+
* Time the inbound seed handler waits for the seed frame before aborting the
|
|
139
|
+
* read (ms). Defaults to {@link DEFAULT_SEED_READ_TIMEOUT_MS}. Bounds a
|
|
140
|
+
* buggy/compromised own-cadre node that opens a stream and never half-closes.
|
|
141
|
+
*/
|
|
142
|
+
seedReadTimeoutMs?: number;
|
|
143
|
+
/**
|
|
144
|
+
* Cap on concurrent inbound seed streams (defaults to
|
|
145
|
+
* {@link DEFAULT_MAX_CONCURRENT_SEEDS}). Over the cap, a non-accepting ack is
|
|
146
|
+
* returned without applying any seed.
|
|
147
|
+
*/
|
|
148
|
+
maxConcurrentSeeds?: number;
|
|
59
149
|
}
|
|
60
150
|
|
|
61
151
|
/**
|
|
@@ -75,17 +165,25 @@ export interface SeedEventCallbacks {
|
|
|
75
165
|
*
|
|
76
166
|
* Seeds solve the cold-start problem: new nodes need control data to validate
|
|
77
167
|
* connections, but can't get data without connecting first. Seeds pre-populate
|
|
78
|
-
* the new node's cache with peer information
|
|
168
|
+
* the new node's cache with peer information.
|
|
79
169
|
*/
|
|
80
170
|
export class SeedBootstrapService {
|
|
81
171
|
private readonly config: SeedBootstrapConfig;
|
|
82
172
|
private libp2pNode: Libp2p | null = null;
|
|
83
173
|
private controlDatabase: ControlDatabase | null = null;
|
|
84
174
|
private readonly authorityPublicKey: string | null;
|
|
175
|
+
private readonly trustPolicy: SeedTrustPolicy;
|
|
176
|
+
private readonly seedReadTimeoutMs: number;
|
|
177
|
+
private readonly maxConcurrentSeeds: number;
|
|
178
|
+
/** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
|
|
179
|
+
private activeStreams = 0;
|
|
85
180
|
private eventCallbacks: SeedEventCallbacks = {};
|
|
86
181
|
|
|
87
182
|
constructor(config: SeedBootstrapConfig) {
|
|
88
183
|
this.config = config;
|
|
184
|
+
this.trustPolicy = config.trustPolicy ?? dbAnchoredTrustPolicy();
|
|
185
|
+
this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
|
|
186
|
+
this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
|
|
89
187
|
|
|
90
188
|
// Derive public key from private key if not provided
|
|
91
189
|
if (config.authorityPrivateKey && !config.authorityPublicKey) {
|
|
@@ -111,87 +209,199 @@ export class SeedBootstrapService {
|
|
|
111
209
|
}
|
|
112
210
|
|
|
113
211
|
/**
|
|
114
|
-
*
|
|
212
|
+
* Whether this service holds an authority private key, i.e. can produce the
|
|
213
|
+
* authority signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
|
|
214
|
+
* and re-authorizations. A seed-listener-only service (`enableSeedListener`,
|
|
215
|
+
* no authority key) returns false: it can receive/apply seeds but cannot author
|
|
216
|
+
* or re-issue authority writes. Used by the write-while-alone re-replication
|
|
217
|
+
* drain to skip authority work on a non-authority node.
|
|
115
218
|
*/
|
|
116
|
-
|
|
219
|
+
canAuthorize(): boolean {
|
|
220
|
+
return !!this.config.authorityPrivateKey;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Initialize the service with libp2p node and control database.
|
|
225
|
+
*
|
|
226
|
+
* `registerHandler` (default true) gates registration of the shared inbound
|
|
227
|
+
* `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
|
|
228
|
+
* (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
|
|
229
|
+
* it on. The throwaway temp services CadreNode builds in `applySeed` /
|
|
230
|
+
* `dialInvite` pass `false`: they only need the stored `libp2pNode` /
|
|
231
|
+
* `controlDatabase` for dialing and known-key lookup, and must NOT bind a
|
|
232
|
+
* discarded closure to the shared node (a handler leak, and a second
|
|
233
|
+
* `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
|
|
234
|
+
*/
|
|
235
|
+
initialize(
|
|
236
|
+
libp2pNode: Libp2p,
|
|
237
|
+
controlDatabase: ControlDatabase,
|
|
238
|
+
options?: { registerHandler?: boolean }
|
|
239
|
+
): void {
|
|
117
240
|
this.libp2pNode = libp2pNode;
|
|
118
241
|
this.controlDatabase = controlDatabase;
|
|
119
|
-
|
|
120
|
-
// Register the seed protocol handler
|
|
121
|
-
|
|
122
|
-
|
|
242
|
+
|
|
243
|
+
// Register the seed protocol handler unless the caller opted out (temp services).
|
|
244
|
+
if (options?.registerHandler ?? true) {
|
|
245
|
+
this.registerProtocolHandler();
|
|
246
|
+
}
|
|
247
|
+
|
|
123
248
|
log('SeedBootstrapService initialized');
|
|
124
249
|
}
|
|
125
250
|
|
|
126
251
|
/**
|
|
127
252
|
* Authorize a new peer to join the cadre.
|
|
128
253
|
* Signs the peer ID with the authority key and inserts into CadrePeer table.
|
|
254
|
+
*
|
|
255
|
+
* The authority vouches the `PublicKey <-> PeerId` binding: rather than trust a
|
|
256
|
+
* caller-supplied key, the binding is enforced by construction — `PublicKey` is
|
|
257
|
+
* DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
|
|
258
|
+
* `PublicKey`, and such a row can never be self-updated (it has no key to
|
|
259
|
+
* verify against), which is correct. The row is inserted with a fresh
|
|
260
|
+
* `UpdatedAt` but no self-signature (`Sig` null) — the authority cannot produce
|
|
261
|
+
* the peer's self-signature, so the peer must self-publish (see
|
|
262
|
+
* {@link CadreNode.registerSelf}) before the row resolves.
|
|
129
263
|
*/
|
|
130
264
|
async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
|
|
131
265
|
const { peerId, multiaddrs } = options;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
266
|
+
log('Authorizing peer: %s', peerId);
|
|
267
|
+
// CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
|
|
268
|
+
const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
|
|
269
|
+
await this.insertCadrePeerRow({
|
|
270
|
+
peerId,
|
|
271
|
+
publicKey: ed25519PublicKeyB64FromPeerId(peerId),
|
|
272
|
+
multiaddr: multiaddrStr,
|
|
273
|
+
updatedAt: Date.now(),
|
|
274
|
+
sig: null,
|
|
275
|
+
});
|
|
276
|
+
log('Peer %s authorized successfully', peerId);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Authority-signed INSERT of this node's OWN self-signed address record.
|
|
281
|
+
*
|
|
282
|
+
* Used by {@link CadreNode.registerSelf} when the node is not yet a member and
|
|
283
|
+
* is its own authority (it holds the authority key): the row is authority-signed
|
|
284
|
+
* (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
|
|
285
|
+
* immediately without a follow-up self-update.
|
|
286
|
+
*/
|
|
287
|
+
async insertSelfPeerRecord(record: PeerAddressRecord): Promise<void> {
|
|
288
|
+
await this.insertCadrePeerRow({
|
|
289
|
+
peerId: record.peerId,
|
|
290
|
+
publicKey: record.publicKey,
|
|
291
|
+
multiaddr: record.addrs.join(','),
|
|
292
|
+
updatedAt: record.updatedAt,
|
|
293
|
+
sig: record.sig,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Shared authority-signed `CadrePeer` INSERT. Signs `digest(peerId)` with the
|
|
299
|
+
* authority key (satisfying `AuthorizedInsert`) and writes the full record
|
|
300
|
+
* row. The authority signature does NOT cover the address columns — those are
|
|
301
|
+
* vouched only as far as the authority asserts them, and a peer's own `Sig`
|
|
302
|
+
* (when present) is what makes the row resolvable.
|
|
303
|
+
*/
|
|
304
|
+
private async insertCadrePeerRow(row: {
|
|
305
|
+
peerId: string;
|
|
306
|
+
publicKey: string | null;
|
|
307
|
+
multiaddr: string;
|
|
308
|
+
updatedAt: number;
|
|
309
|
+
sig: string | null;
|
|
310
|
+
}): Promise<void> {
|
|
311
|
+
const signature = this.signPeerAuthorization(row.peerId);
|
|
137
312
|
if (!this.controlDatabase) {
|
|
138
313
|
throw new Error('Control database not initialized');
|
|
139
314
|
}
|
|
140
|
-
|
|
141
|
-
log('Authorizing peer: %s', peerId);
|
|
142
|
-
|
|
143
|
-
// Sign the peer ID with the authority key
|
|
144
|
-
const peerIdDigest = digest(peerId, 'sha256', 'utf8', 'base64url') as string;
|
|
145
|
-
const signature = sign(
|
|
146
|
-
peerIdDigest,
|
|
147
|
-
this.config.authorityPrivateKey,
|
|
148
|
-
'ed25519',
|
|
149
|
-
'base64url',
|
|
150
|
-
'base64url',
|
|
151
|
-
'base64url'
|
|
152
|
-
) as string;
|
|
153
|
-
|
|
154
|
-
// Insert into CadrePeer table with authority context
|
|
155
315
|
const db = this.controlDatabase.getDatabase();
|
|
156
|
-
// CadrePeer.Multiaddr is NOT NULL; use empty string when no addrs provided
|
|
157
|
-
const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
|
|
158
|
-
|
|
159
316
|
await db.exec(`
|
|
160
|
-
insert into CadreControl.CadrePeer (PeerId, Multiaddr)
|
|
317
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig)
|
|
161
318
|
with context AuthorityKey = ?, Signature = ?
|
|
162
|
-
values (?, ?)
|
|
163
|
-
`, [this.authorityPublicKey, signature, peerId,
|
|
164
|
-
|
|
165
|
-
log('Peer %s authorized successfully', peerId);
|
|
319
|
+
values (?, ?, ?, ?, ?)
|
|
320
|
+
`, [this.authorityPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig]);
|
|
166
321
|
}
|
|
167
322
|
|
|
168
323
|
/**
|
|
169
|
-
*
|
|
324
|
+
* Authority-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
|
|
170
325
|
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
326
|
+
* Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
|
|
327
|
+
* row is authority-signed (satisfying `DeviceToken.AuthorizedInsert`, which vouches
|
|
328
|
+
* membership exactly as `CadrePeer.AuthorizedInsert` does) AND carries the peer's
|
|
329
|
+
* own self-`Sig` over the token payload. The authority signature covers only the
|
|
330
|
+
* PeerId — it does NOT vouch the token contents; the peer's `Sig` (verified at
|
|
331
|
+
* resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
|
|
332
|
+
* resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
|
|
333
|
+
* when the node is its own authority.
|
|
174
334
|
*/
|
|
175
|
-
async
|
|
176
|
-
|
|
177
|
-
|
|
335
|
+
async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
|
|
336
|
+
const signature = this.signPeerAuthorization(record.peerId);
|
|
337
|
+
if (!this.controlDatabase) {
|
|
338
|
+
throw new Error('Control database not initialized');
|
|
178
339
|
}
|
|
340
|
+
const db = this.controlDatabase.getDatabase();
|
|
341
|
+
await db.exec(`
|
|
342
|
+
insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig)
|
|
343
|
+
with context AuthorityKey = ?, Signature = ?
|
|
344
|
+
values (?, ?, ?, ?, ?)
|
|
345
|
+
`, [this.authorityPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig]);
|
|
346
|
+
log('Device token inserted (authority-signed): %s', record.peerId);
|
|
347
|
+
}
|
|
179
348
|
|
|
349
|
+
/**
|
|
350
|
+
* Authority-signed DELETE of a peer's `DeviceToken` row (logout / token
|
|
351
|
+
* invalidation). The `DeviceToken.AuthorizedInsert` constraint gates both insert
|
|
352
|
+
* AND delete on an authority signature over `digest(old.PeerId)`, so — like
|
|
353
|
+
* {@link removePeer} for `CadrePeer` — clearing a token requires the authority key.
|
|
354
|
+
*/
|
|
355
|
+
async deleteDeviceToken(peerId: string): Promise<void> {
|
|
356
|
+
const signature = this.signPeerAuthorization(peerId);
|
|
180
357
|
if (!this.controlDatabase) {
|
|
181
358
|
throw new Error('Control database not initialized');
|
|
182
359
|
}
|
|
360
|
+
const db = this.controlDatabase.getDatabase();
|
|
361
|
+
await db.exec(`
|
|
362
|
+
delete from CadreControl.DeviceToken
|
|
363
|
+
with context AuthorityKey = ?, Signature = ?
|
|
364
|
+
where PeerId = ?
|
|
365
|
+
`, [this.authorityPublicKey, signature, peerId]);
|
|
366
|
+
log('Device token removed (authority-signed): %s', peerId);
|
|
367
|
+
}
|
|
183
368
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
369
|
+
/**
|
|
370
|
+
* Sign a peer ID with the authority key for a `CadrePeer` / `DeviceToken`
|
|
371
|
+
* insert-or-delete. The signed bytes come from the shared
|
|
372
|
+
* {@link peerAuthorizationDigest} helper so the offline `cadre enroll register`
|
|
373
|
+
* verifier checks the exact same construction. Throws if no authority key is set.
|
|
374
|
+
*/
|
|
375
|
+
private signPeerAuthorization(peerId: string): string {
|
|
376
|
+
if (!this.config.authorityPrivateKey) {
|
|
377
|
+
throw new Error('Authority private key required to authorize peers');
|
|
378
|
+
}
|
|
379
|
+
return sign(
|
|
380
|
+
peerAuthorizationDigest(peerId),
|
|
189
381
|
this.config.authorityPrivateKey,
|
|
190
382
|
'ed25519',
|
|
191
383
|
'base64url',
|
|
192
384
|
'base64url',
|
|
193
385
|
'base64url'
|
|
194
386
|
) as string;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Remove a peer from the cadre by authority signature.
|
|
391
|
+
*
|
|
392
|
+
* The constraint over CadrePeer's `check on insert, delete` validates a
|
|
393
|
+
* signature over `digest(old.PeerId)` by an authority key. We use the same
|
|
394
|
+
* digest pattern as authorizePeer.
|
|
395
|
+
*/
|
|
396
|
+
async removePeer(peerId: string): Promise<void> {
|
|
397
|
+
// Same canonical digest as the authorizing INSERT (see peerAuthorizationDigest);
|
|
398
|
+
// also validates the authority key is present before touching the database.
|
|
399
|
+
const signature = this.signPeerAuthorization(peerId);
|
|
400
|
+
if (!this.controlDatabase) {
|
|
401
|
+
throw new Error('Control database not initialized');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
log('Removing peer: %s', peerId);
|
|
195
405
|
|
|
196
406
|
const db = this.controlDatabase.getDatabase();
|
|
197
407
|
await db.exec(`
|
|
@@ -203,6 +413,45 @@ export class SeedBootstrapService {
|
|
|
203
413
|
log('Peer %s removed successfully', peerId);
|
|
204
414
|
}
|
|
205
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Authority "re-touch" of an existing `CadrePeer` membership row: bump
|
|
418
|
+
* `UpdatedAt` under the authority branch of `CadrePeer.AuthorizedUpdate` (a
|
|
419
|
+
* signature over `digest(peerId)` — the same construction {@link authorizePeer}
|
|
420
|
+
* uses) so the row is re-emitted as a fresh, broadcasting transaction.
|
|
421
|
+
*
|
|
422
|
+
* This is the write-while-alone re-replication primitive
|
|
423
|
+
* (`control-write-ensure-replicated`): a membership row that committed
|
|
424
|
+
* local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
|
|
425
|
+
* grows, by re-issuing this monotonic bump. It is an UPDATE (not the original
|
|
426
|
+
* INSERT) because the row already exists locally; a re-INSERT would hit the
|
|
427
|
+
* `PeerId` PK. Only the freshness stamp changes — `PublicKey` / `Multiaddr` /
|
|
428
|
+
* `Sig` are left intact — so it is safe over a row whose peer has not
|
|
429
|
+
* self-published (`Sig` null); the caller must skip a row that already carries a
|
|
430
|
+
* self-`Sig` (that row is the owning peer's to refresh, and bumping `UpdatedAt`
|
|
431
|
+
* without re-signing would invalidate its self-signature).
|
|
432
|
+
*
|
|
433
|
+
* @param peerId - the membership row to re-touch.
|
|
434
|
+
* @param updatedAt - the strictly-increasing freshness stamp to write.
|
|
435
|
+
* @throws if no authority private key is configured (a non-authority cannot
|
|
436
|
+
* re-sign another peer's row) or the control database is not initialized.
|
|
437
|
+
*/
|
|
438
|
+
async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
|
|
439
|
+
// Reuses the exact digest authorizePeer signs (peerAuthorizationDigest), which
|
|
440
|
+
// the authority branch of AuthorizedUpdate verifies; throws if no authority key.
|
|
441
|
+
const signature = this.signPeerAuthorization(peerId);
|
|
442
|
+
if (!this.controlDatabase) {
|
|
443
|
+
throw new Error('Control database not initialized');
|
|
444
|
+
}
|
|
445
|
+
const db = this.controlDatabase.getDatabase();
|
|
446
|
+
await db.exec(`
|
|
447
|
+
update CadreControl.CadrePeer
|
|
448
|
+
with context AuthorityKey = ?, Signature = ?
|
|
449
|
+
set UpdatedAt = ?
|
|
450
|
+
where PeerId = ?
|
|
451
|
+
`, [this.authorityPublicKey, signature, updatedAt, peerId]);
|
|
452
|
+
log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
|
|
453
|
+
}
|
|
454
|
+
|
|
206
455
|
/**
|
|
207
456
|
* Create a seed from the current control network state.
|
|
208
457
|
* The seed contains peer information and is signed by an authority.
|
|
@@ -227,9 +476,9 @@ export class SeedBootstrapService {
|
|
|
227
476
|
peers,
|
|
228
477
|
};
|
|
229
478
|
|
|
230
|
-
// Sign the seed
|
|
231
|
-
const seedJson =
|
|
232
|
-
const seedDigest = digest(seedJson, 'sha256', '
|
|
479
|
+
// Sign the seed over its canonical byte representation
|
|
480
|
+
const seedJson = canonicalSeedPayload(seedData);
|
|
481
|
+
const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
|
|
233
482
|
const signature = sign(
|
|
234
483
|
seedDigest,
|
|
235
484
|
this.config.authorityPrivateKey,
|
|
@@ -251,9 +500,23 @@ export class SeedBootstrapService {
|
|
|
251
500
|
|
|
252
501
|
/**
|
|
253
502
|
* Apply a seed to populate the peer cache and enable connections.
|
|
254
|
-
*
|
|
503
|
+
*
|
|
504
|
+
* Validates the seed signature, then evaluates a trust anchor for the
|
|
505
|
+
* `signerKey` that does NOT come from the seed body: the receiver's
|
|
506
|
+
* `AuthorityKey` table (DB-anchored), optionally augmented by pinned keys or
|
|
507
|
+
* TOFU via the configured/overriding `SeedTrustPolicy`. A forged
|
|
508
|
+
* self-asserting seed — one that merely lists its own signer as an authority
|
|
509
|
+
* peer — no longer passes.
|
|
510
|
+
*
|
|
511
|
+
* @param seed - The seed to apply (already transport-decoded).
|
|
512
|
+
* @param options.trustPolicy - Per-call policy override (e.g. a
|
|
513
|
+
* `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
|
|
514
|
+
* service-configured default for this seed only.
|
|
255
515
|
*/
|
|
256
|
-
async applySeed(
|
|
516
|
+
async applySeed(
|
|
517
|
+
seed: ControlNetworkSeed,
|
|
518
|
+
options?: { trustPolicy?: SeedTrustPolicy }
|
|
519
|
+
): Promise<ApplySeedResult> {
|
|
257
520
|
if (!this.libp2pNode) {
|
|
258
521
|
return { success: false, peersAdded: 0, error: 'Service not initialized' };
|
|
259
522
|
}
|
|
@@ -265,12 +528,24 @@ export class SeedBootstrapService {
|
|
|
265
528
|
return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
|
|
266
529
|
}
|
|
267
530
|
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
531
|
+
// Evaluate the trust anchor for the signer key. The known-authority set is
|
|
532
|
+
// sourced from the receiver's control DB, never from the seed itself; a
|
|
533
|
+
// cold-start node with no DB and no override therefore sees an empty set.
|
|
534
|
+
const knownAuthorityKeys = this.controlDatabase
|
|
535
|
+
? await this.controlDatabase.getAuthorityKeys()
|
|
536
|
+
: new Set<string>();
|
|
537
|
+
const policy = options?.trustPolicy ?? this.trustPolicy;
|
|
538
|
+
const decision = await policy.evaluate({
|
|
539
|
+
partyId: seed.partyId,
|
|
540
|
+
signerKey: seed.signerKey,
|
|
541
|
+
knownAuthorityKeys,
|
|
542
|
+
});
|
|
543
|
+
if (!decision.trusted) {
|
|
544
|
+
return {
|
|
545
|
+
success: false,
|
|
546
|
+
peersAdded: 0,
|
|
547
|
+
error: decision.reason ?? 'Signer key not trusted by trust policy',
|
|
548
|
+
};
|
|
274
549
|
}
|
|
275
550
|
|
|
276
551
|
let peersAdded = 0;
|
|
@@ -345,26 +620,18 @@ export class SeedBootstrapService {
|
|
|
345
620
|
|
|
346
621
|
// Dial the target and open a stream
|
|
347
622
|
const rawStream = await this.libp2pNode.dialProtocol(addr, SEED_PROTOCOL);
|
|
348
|
-
const stream = rawStream as unknown as
|
|
623
|
+
const stream = rawStream as unknown as ControlStream;
|
|
349
624
|
|
|
350
625
|
try {
|
|
351
|
-
// Send the seed message
|
|
626
|
+
// Send the seed message as one length-prefixed frame (shared writer).
|
|
352
627
|
const message: SeedMessage = {
|
|
353
628
|
partyId: seed.partyId,
|
|
354
629
|
peers: seed.peers,
|
|
355
|
-
transactions: seed.transactions,
|
|
356
630
|
signature: seed.signature,
|
|
357
631
|
signerKey: seed.signerKey,
|
|
358
632
|
};
|
|
359
633
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
// Write length-prefixed message using libp2p 3.x send() API
|
|
363
|
-
const lengthBytes = new Uint8Array(4);
|
|
364
|
-
new DataView(lengthBytes.buffer).setUint32(0, messageBytes.length, false);
|
|
365
|
-
|
|
366
|
-
stream.send(lengthBytes);
|
|
367
|
-
stream.send(messageBytes);
|
|
634
|
+
writeFrame(stream, message);
|
|
368
635
|
|
|
369
636
|
// In libp2p v3.x, close() closes the write end only (signals EOF),
|
|
370
637
|
// while the read end remains open for receiving the ack.
|
|
@@ -385,8 +652,8 @@ export class SeedBootstrapService {
|
|
|
385
652
|
}
|
|
386
653
|
|
|
387
654
|
// Parse length-prefixed response
|
|
388
|
-
const
|
|
389
|
-
const responseJson = new TextDecoder().decode(
|
|
655
|
+
const responseBody = decodeLengthPrefixedFrame(responseData);
|
|
656
|
+
const responseJson = new TextDecoder().decode(responseBody);
|
|
390
657
|
const ack = JSON.parse(responseJson) as SeedAckMessage;
|
|
391
658
|
|
|
392
659
|
log('Seed delivery response: accepted=%s', ack.accepted);
|
|
@@ -420,15 +687,11 @@ export class SeedBootstrapService {
|
|
|
420
687
|
*/
|
|
421
688
|
validateSeedSignature(seed: ControlNetworkSeed): boolean {
|
|
422
689
|
try {
|
|
423
|
-
// Reconstruct the signed
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
const seedJson = JSON.stringify(seedData);
|
|
431
|
-
const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url') as string;
|
|
690
|
+
// Reconstruct the signed bytes via the shared canonical payload builder so
|
|
691
|
+
// verification is independent of key order. The payload is the fixed
|
|
692
|
+
// `{ partyId, peers }` the producer emits.
|
|
693
|
+
const seedJson = canonicalSeedPayload(seed);
|
|
694
|
+
const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
|
|
432
695
|
|
|
433
696
|
return verify(
|
|
434
697
|
seedDigest,
|
|
@@ -447,34 +710,39 @@ export class SeedBootstrapService {
|
|
|
447
710
|
|
|
448
711
|
/**
|
|
449
712
|
* Query peers from the control database.
|
|
713
|
+
*
|
|
714
|
+
* Authority identity is sourced from the `AuthorityKey` table, not from the
|
|
715
|
+
* transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
|
|
716
|
+
* multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
|
|
717
|
+
* peer is an authority iff that derived key is in the `AuthorityKey` set.
|
|
718
|
+
* This makes any authority node markable — not just the local one — and ties
|
|
719
|
+
* `isAuthority` to the control table rather than to `peerId === self`.
|
|
450
720
|
*/
|
|
451
721
|
private async queryPeers(): Promise<SeedPeer[]> {
|
|
452
722
|
if (!this.controlDatabase) {
|
|
453
723
|
return [];
|
|
454
724
|
}
|
|
455
725
|
|
|
726
|
+
const authorityKeys = await this.controlDatabase.getAuthorityKeys();
|
|
456
727
|
const db = this.controlDatabase.getDatabase();
|
|
457
728
|
const peers: SeedPeer[] = [];
|
|
458
729
|
|
|
459
|
-
// First, get all authority keys
|
|
460
|
-
const authorityKeys = new Set<string>();
|
|
461
|
-
for await (const row of db.eval('select Key from CadreControl.AuthorityKey')) {
|
|
462
|
-
authorityKeys.add(row.Key as string);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
730
|
// Query CadrePeer table
|
|
466
731
|
for await (const row of db.eval('select PeerId, Multiaddr from CadreControl.CadrePeer')) {
|
|
467
732
|
const peerId = row.PeerId as string;
|
|
468
733
|
const multiaddr = row.Multiaddr as string | null;
|
|
469
734
|
|
|
470
|
-
//
|
|
471
|
-
|
|
735
|
+
// Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
|
|
736
|
+
// unparsable id yields null and is treated as a non-authority rather than
|
|
737
|
+
// failing the whole seed creation.
|
|
738
|
+
const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
|
|
739
|
+
const isAuthority = pubKeyB64 !== null && authorityKeys.has(pubKeyB64);
|
|
472
740
|
|
|
473
741
|
peers.push({
|
|
474
742
|
peerId,
|
|
475
743
|
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
476
744
|
isAuthority,
|
|
477
|
-
...(isAuthority
|
|
745
|
+
...(isAuthority ? { publicKey: pubKeyB64 } : {}),
|
|
478
746
|
});
|
|
479
747
|
}
|
|
480
748
|
|
|
@@ -482,105 +750,106 @@ export class SeedBootstrapService {
|
|
|
482
750
|
}
|
|
483
751
|
|
|
484
752
|
/**
|
|
485
|
-
* Register the seed protocol handler.
|
|
753
|
+
* Register the seed protocol handler. The inbound closure just delegates to
|
|
754
|
+
* {@link handleSeedStream} — extracted as a method so it has a unit-test seam
|
|
755
|
+
* (mirroring wake's `handleStream`) the inline closure never had.
|
|
486
756
|
*/
|
|
487
757
|
private registerProtocolHandler(): void {
|
|
488
758
|
if (!this.libp2pNode) return;
|
|
489
759
|
|
|
490
|
-
this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
|
|
491
|
-
const stream = rawStream as LibP2PStream;
|
|
760
|
+
void this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
|
|
492
761
|
const remotePeerId = (rawConnection as Connection).remotePeer.toString();
|
|
493
|
-
|
|
762
|
+
await this.handleSeedStream(rawStream as ControlStream, remotePeerId);
|
|
763
|
+
});
|
|
494
764
|
|
|
765
|
+
log('Registered seed protocol handler: %s', SEED_PROTOCOL);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Read one inbound seed frame, apply it, and write the ack.
|
|
770
|
+
*
|
|
771
|
+
* Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
|
|
772
|
+
* {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
|
|
773
|
+
* that never half-closes is aborted inside `readStreamToEnd`), and the existing
|
|
774
|
+
* malformed/oversized-frame guard — all reported as a non-accepting
|
|
775
|
+
* {@link SeedAckMessage} rather than a dropped/hung stream.
|
|
776
|
+
*/
|
|
777
|
+
private async handleSeedStream(stream: ControlStream, remotePeerId: string): Promise<void> {
|
|
778
|
+
log('Incoming seed delivery from: %s', remotePeerId);
|
|
779
|
+
|
|
780
|
+
if (this.activeStreams >= this.maxConcurrentSeeds) {
|
|
781
|
+
log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
|
|
782
|
+
const ack: SeedAckMessage = { accepted: false, reason: 'Too many concurrent seed deliveries' };
|
|
495
783
|
try {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
784
|
+
writeFrame(stream, ack);
|
|
785
|
+
} catch {
|
|
786
|
+
// Ignore send errors on the reject path.
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
await stream.close();
|
|
790
|
+
} catch {
|
|
791
|
+
// Ignore close errors.
|
|
792
|
+
}
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
508
795
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
796
|
+
this.activeStreams++;
|
|
797
|
+
try {
|
|
798
|
+
// Read the seed frame to EOF (bounded + size-capped), then decode it.
|
|
799
|
+
const data = await readStreamToEnd(stream, {
|
|
800
|
+
maxBytes: MAX_SEED_SIZE,
|
|
801
|
+
timeoutMs: this.seedReadTimeoutMs,
|
|
802
|
+
label: 'Seed',
|
|
803
|
+
});
|
|
515
804
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const message = JSON.parse(messageJson) as SeedMessage;
|
|
520
|
-
|
|
521
|
-
// Emit seed received event
|
|
522
|
-
this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
|
|
523
|
-
|
|
524
|
-
// Convert to seed and apply
|
|
525
|
-
const seed: ControlNetworkSeed = {
|
|
526
|
-
partyId: message.partyId,
|
|
527
|
-
peers: message.peers,
|
|
528
|
-
transactions: message.transactions,
|
|
529
|
-
signature: message.signature,
|
|
530
|
-
signerKey: message.signerKey,
|
|
531
|
-
};
|
|
532
|
-
|
|
533
|
-
const result = await this.applySeed(seed);
|
|
534
|
-
|
|
535
|
-
// Emit appropriate event based on result
|
|
536
|
-
if (result.success) {
|
|
537
|
-
this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
|
|
538
|
-
} else {
|
|
539
|
-
this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
|
|
540
|
-
}
|
|
805
|
+
const messageBody = decodeLengthPrefixedFrame(data);
|
|
806
|
+
const messageJson = new TextDecoder().decode(messageBody);
|
|
807
|
+
const message = JSON.parse(messageJson) as SeedMessage;
|
|
541
808
|
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
accepted: result.success,
|
|
545
|
-
reason: result.error,
|
|
546
|
-
};
|
|
809
|
+
// Emit seed received event
|
|
810
|
+
this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
|
|
547
811
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
812
|
+
// Convert to seed and apply
|
|
813
|
+
const seed: ControlNetworkSeed = {
|
|
814
|
+
partyId: message.partyId,
|
|
815
|
+
peers: message.peers,
|
|
816
|
+
signature: message.signature,
|
|
817
|
+
signerKey: message.signerKey,
|
|
818
|
+
};
|
|
551
819
|
|
|
552
|
-
|
|
553
|
-
stream.send(ackBytes);
|
|
820
|
+
const result = await this.applySeed(seed);
|
|
554
821
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
|
|
561
|
-
|
|
562
|
-
// Send error acknowledgment
|
|
563
|
-
const ack: SeedAckMessage = {
|
|
564
|
-
accepted: false,
|
|
565
|
-
reason: errorMessage,
|
|
566
|
-
};
|
|
567
|
-
|
|
568
|
-
const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
|
|
569
|
-
const lengthBytes = new Uint8Array(4);
|
|
570
|
-
new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
|
|
571
|
-
|
|
572
|
-
try {
|
|
573
|
-
stream.send(lengthBytes);
|
|
574
|
-
stream.send(ackBytes);
|
|
575
|
-
} catch {
|
|
576
|
-
// Ignore send errors
|
|
577
|
-
}
|
|
578
|
-
} finally {
|
|
579
|
-
await stream.close();
|
|
822
|
+
// Emit appropriate event based on result
|
|
823
|
+
if (result.success) {
|
|
824
|
+
this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
|
|
825
|
+
} else {
|
|
826
|
+
this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
|
|
580
827
|
}
|
|
581
|
-
});
|
|
582
828
|
|
|
583
|
-
|
|
829
|
+
const ack: SeedAckMessage = { accepted: result.success, reason: result.error };
|
|
830
|
+
writeFrame(stream, ack);
|
|
831
|
+
} catch (error) {
|
|
832
|
+
log('Error handling seed delivery: %o', error);
|
|
833
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
834
|
+
|
|
835
|
+
// Emit error event
|
|
836
|
+
this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
|
|
837
|
+
|
|
838
|
+
// Send error acknowledgment (best-effort on the still-open write side).
|
|
839
|
+
const ack: SeedAckMessage = { accepted: false, reason: errorMessage };
|
|
840
|
+
try {
|
|
841
|
+
writeFrame(stream, ack);
|
|
842
|
+
} catch {
|
|
843
|
+
// Ignore send errors
|
|
844
|
+
}
|
|
845
|
+
} finally {
|
|
846
|
+
this.activeStreams--;
|
|
847
|
+
try {
|
|
848
|
+
await stream.close();
|
|
849
|
+
} catch {
|
|
850
|
+
// Ignore close errors.
|
|
851
|
+
}
|
|
852
|
+
}
|
|
584
853
|
}
|
|
585
854
|
|
|
586
855
|
/**
|
|
@@ -664,10 +933,17 @@ export class SeedBootstrapService {
|
|
|
664
933
|
authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
665
934
|
}
|
|
666
935
|
|
|
936
|
+
// Carry the cadre's authority keys out-of-band so a cold-start invitee can
|
|
937
|
+
// pin the trusted authority set before applying any seed.
|
|
938
|
+
const authorityKeys = this.controlDatabase
|
|
939
|
+
? Array.from(await this.controlDatabase.getAuthorityKeys())
|
|
940
|
+
: [];
|
|
941
|
+
|
|
667
942
|
const now = Date.now();
|
|
668
943
|
const invite: CadreInvite = {
|
|
669
944
|
partyId: this.config.partyId,
|
|
670
945
|
authorityAddrs,
|
|
946
|
+
authorityKeys: authorityKeys.length ? authorityKeys : undefined,
|
|
671
947
|
token,
|
|
672
948
|
createdAt: now,
|
|
673
949
|
expiresAt: expiresIn ? now + expiresIn : undefined,
|
|
@@ -675,7 +951,7 @@ export class SeedBootstrapService {
|
|
|
675
951
|
|
|
676
952
|
const encodedInvite = this.encodeInvite(invite);
|
|
677
953
|
|
|
678
|
-
log('Invite created with %d authority addresses', authorityAddrs.length);
|
|
954
|
+
log('Invite created with %d authority addresses, %d authority keys', authorityAddrs.length, authorityKeys.length);
|
|
679
955
|
|
|
680
956
|
return { invite, encodedInvite };
|
|
681
957
|
}
|