@serfab/cadre-core 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cadre-node.d.ts +148 -44
- package/dist/cadre-node.js +363 -139
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-database.d.ts +105 -14
- package/dist/control-database.js +242 -73
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +6 -6
- package/dist/control-formation-recorder.js +7 -8
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +1066 -979
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +21 -15
- package/dist/control-write-retry.js +21 -15
- package/dist/control-write-retry.js.map +1 -1
- package/dist/diagnostics/connection-path.d.ts +1 -18
- package/dist/diagnostics/connection-path.js +1 -20
- package/dist/diagnostics/connection-path.js.map +1 -1
- package/dist/identity-key.d.ts +0 -30
- package/dist/identity-key.js +7 -40
- package/dist/identity-key.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +32 -0
- package/dist/invitation-bootstrap.js +30 -0
- package/dist/invitation-bootstrap.js.map +1 -0
- package/dist/link-budget.d.ts +204 -119
- package/dist/link-budget.js +208 -120
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +3 -3
- package/dist/membership-connection-gater.js +3 -3
- package/dist/peer-dial.d.ts +18 -14
- package/dist/peer-dial.js +18 -14
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +3 -3
- package/dist/pending-join-runner.d.ts +186 -0
- package/dist/pending-join-runner.js +649 -0
- package/dist/pending-join-runner.js.map +1 -0
- package/dist/push-fanout.d.ts +1 -1
- package/dist/push-fanout.js +1 -1
- package/dist/relay-stun.d.ts +24 -0
- package/dist/relay-stun.js +51 -0
- package/dist/relay-stun.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +2 -2
- package/dist/seed-bootstrap.js +5 -5
- package/dist/strand-addr-protocol.js +2 -2
- package/dist/strand-formation-deadlines.d.ts +5 -5
- package/dist/strand-formation-deadlines.js +5 -5
- package/dist/strand-formation-manager.d.ts +18 -4
- package/dist/strand-formation-manager.js +48 -33
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +71 -14
- package/dist/strand-formation-protocol.js +222 -47
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-formation-rejection.d.ts +94 -0
- package/dist/strand-formation-rejection.js +77 -0
- package/dist/strand-formation-rejection.js.map +1 -0
- package/dist/strand-instance-manager.js +4 -1
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.js +32 -32
- package/dist/strand-solicitation.d.ts +11 -3
- package/dist/strand-solicitation.js +9 -0
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/types.d.ts +103 -17
- package/dist/types.js +5 -9
- package/dist/types.js.map +1 -1
- package/package.json +22 -22
- package/src/cadre-node.ts +8654 -8400
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +128 -128
- package/src/control-database.ts +303 -82
- package/src/control-formation-recorder.ts +353 -354
- package/src/control-read-retry.ts +216 -216
- package/src/control-schema.ts +1078 -991
- package/src/control-write-retry.ts +567 -561
- package/src/diagnostics/connection-path.ts +1 -29
- package/src/formation-approval.ts +610 -610
- package/src/hibernation-manager.ts +638 -638
- package/src/identity-key.ts +7 -62
- package/src/index.ts +644 -627
- package/src/invitation-bootstrap.ts +40 -0
- package/src/joined-strand-store.ts +498 -498
- package/src/link-budget.ts +684 -583
- package/src/membership-connection-gater.ts +570 -570
- package/src/node-local-snapshot.ts +298 -298
- package/src/peer-dial.ts +315 -311
- package/src/peer-join-backfill.ts +710 -710
- package/src/pending-join-runner.ts +735 -0
- package/src/push-fanout.ts +1 -1
- package/src/relay-reservation.ts +1121 -1121
- package/src/relay-server.ts +98 -98
- package/src/relay-stun.ts +58 -0
- package/src/seed-bootstrap.ts +1448 -1448
- package/src/strand-addr-protocol.ts +547 -547
- package/src/strand-database.ts +287 -287
- package/src/strand-first-sync-gate.ts +391 -391
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +725 -700
- package/src/strand-formation-protocol.ts +1036 -802
- package/src/strand-formation-rejection.ts +129 -0
- package/src/strand-fret-addrs.ts +145 -145
- package/src/strand-instance-manager.ts +1789 -1786
- package/src/strand-membership-writer.ts +1974 -1974
- package/src/strand-network-state-file.ts +40 -40
- package/src/strand-network-state.ts +243 -243
- package/src/strand-revocation-enforcer.ts +654 -654
- package/src/strand-solicitation.ts +532 -519
- package/src/strand-wake-protocol.ts +401 -401
- package/src/types.ts +2222 -2140
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
- package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
- package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
- package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
package/src/seed-bootstrap.ts
CHANGED
|
@@ -1,1448 +1,1448 @@
|
|
|
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, Connection } from '@libp2p/interface';
|
|
5
|
-
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
6
|
-
import { peerIdFromString } from '@libp2p/peer-id';
|
|
7
|
-
import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
|
|
8
|
-
import { dialPeerAddrs, SelfRelayOnlyError, DEFAULT_PEER_DIAL_BUDGET, type PeerDialBudget } from './peer-dial.js';
|
|
9
|
-
import { relayedRequestBudgetMs } from './link-budget.js';
|
|
10
|
-
import type {
|
|
11
|
-
ControlNetworkSeed,
|
|
12
|
-
SeedPeer,
|
|
13
|
-
SeedMessage,
|
|
14
|
-
SeedAckMessage,
|
|
15
|
-
AuthorizePeerOptions,
|
|
16
|
-
ApplySeedResult,
|
|
17
|
-
AddDroneOptions,
|
|
18
|
-
AddPhoneOptions,
|
|
19
|
-
DroneInitResult,
|
|
20
|
-
InviteResult,
|
|
21
|
-
CadreInvite,
|
|
22
|
-
PeerAddressRecord,
|
|
23
|
-
DeviceTokenRecord,
|
|
24
|
-
RevocationRow,
|
|
25
|
-
RevocationLedgerOpenResult
|
|
26
|
-
} from './types.js';
|
|
27
|
-
import type { ControlDatabase } from './control-database.js';
|
|
28
|
-
import { generateStampId } from './control-database.js';
|
|
29
|
-
import { canonicalJson } from './canonical-json.js';
|
|
30
|
-
import { deviceTokenAddDigest } from './peer-authorization.js';
|
|
31
|
-
import {
|
|
32
|
-
type SeedTrustPolicy,
|
|
33
|
-
type SeedTrustDecision,
|
|
34
|
-
anchoredTrustPolicy,
|
|
35
|
-
} from './seed-trust-policy.js';
|
|
36
|
-
import type { TrustedOwnerStore } from './trusted-owner-store.js';
|
|
37
|
-
|
|
38
|
-
const log = debug('sereus:cadre:seed-bootstrap');
|
|
39
|
-
|
|
40
|
-
/** Protocol ID for seed delivery */
|
|
41
|
-
export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
|
|
42
|
-
|
|
43
|
-
/** Maximum seed message size (1MB) */
|
|
44
|
-
const MAX_SEED_SIZE = 1024 * 1024;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Default time the receiver waits for an inbound seed frame before aborting (ms). It covers the
|
|
48
|
-
* read only: the trust decision and the peer-store merge run after it.
|
|
49
|
-
*/
|
|
50
|
-
// eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one seed frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
|
|
51
|
-
const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
|
|
52
|
-
|
|
53
|
-
/** Default cap on concurrent inbound seed streams a single peer can pin open. */
|
|
54
|
-
const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
|
|
58
|
-
*
|
|
59
|
-
* Guards every parse site against malformed input: a buffer too short to hold
|
|
60
|
-
* the prefix, a declared length exceeding `maxLength`, and a declared length
|
|
61
|
-
* exceeding the bytes actually present. Returns a view (`subarray`, no copy) —
|
|
62
|
-
* the body is handed straight to `TextDecoder`.
|
|
63
|
-
*/
|
|
64
|
-
export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED_SIZE): Uint8Array {
|
|
65
|
-
if (data.length < 4) {
|
|
66
|
-
throw new Error(`Seed frame too short: ${data.length} bytes, need ≥4 for length prefix`);
|
|
67
|
-
}
|
|
68
|
-
// Pass the full (buffer, byteOffset, byteLength) triple so the read is correct
|
|
69
|
-
// even for a non-zero-offset view, not just the fresh zero-offset arrays
|
|
70
|
-
// current callers pass.
|
|
71
|
-
const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0, false);
|
|
72
|
-
const available = data.length - 4;
|
|
73
|
-
if (length > maxLength) {
|
|
74
|
-
throw new Error(`Seed frame declares length ${length} exceeding max ${maxLength}`);
|
|
75
|
-
}
|
|
76
|
-
if (length > available) {
|
|
77
|
-
throw new Error(`Seed frame declares length ${length} but only ${available} body bytes present`);
|
|
78
|
-
}
|
|
79
|
-
return data.subarray(4, 4 + length);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
|
|
84
|
-
*
|
|
85
|
-
* An Ed25519 PeerId is an identity multihash of the public key, so
|
|
86
|
-
* `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
|
|
87
|
-
* base64url form matches the `OwnerKey.Key` representation (and
|
|
88
|
-
* `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
|
|
89
|
-
* id, a missing embedded key, or any parse failure — callers treat null as
|
|
90
|
-
* "not an owner" rather than throwing.
|
|
91
|
-
*/
|
|
92
|
-
export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
|
|
93
|
-
try {
|
|
94
|
-
const parsed = peerIdFromString(peerId);
|
|
95
|
-
if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
|
|
99
|
-
} catch {
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Canonical byte representation of the authenticated seed fields.
|
|
106
|
-
*
|
|
107
|
-
* Routes both the creator (`createSeed`) and the verifier
|
|
108
|
-
* (`validateSeedSignature`) through one builder so the signed bytes are
|
|
109
|
-
* identical regardless of key insertion order. `canonicalJson` sorts keys and
|
|
110
|
-
* drops `undefined`, so the signed payload is exactly `{ partyId, peers }` —
|
|
111
|
-
* the fields the producer actually emits.
|
|
112
|
-
*/
|
|
113
|
-
export function canonicalSeedPayload(
|
|
114
|
-
seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>
|
|
115
|
-
): string {
|
|
116
|
-
return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
|
|
121
|
-
* so the dial counters read zero rather than being absent.
|
|
122
|
-
*/
|
|
123
|
-
function seedRejected(error: string): ApplySeedResult {
|
|
124
|
-
return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** What dialing a seed's owner peers produced — the owner-dial half of an {@link ApplySeedResult}. */
|
|
128
|
-
type OwnerDialCounts = Pick<ApplySeedResult, 'ownerDialsAttempted' | 'ownerDialsFailed'>;
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Parse each address, dropping (and logging) any that is malformed, so one bad
|
|
132
|
-
* entry cannot keep a peer's valid addresses from being dialed.
|
|
133
|
-
*/
|
|
134
|
-
function parseDialAddrs(addrs: readonly string[]): Multiaddr[] {
|
|
135
|
-
const parsed: Multiaddr[] = [];
|
|
136
|
-
for (const addr of addrs) {
|
|
137
|
-
try {
|
|
138
|
-
parsed.push(multiaddr(addr));
|
|
139
|
-
} catch (error) {
|
|
140
|
-
log('Skipping malformed dial address %s: %o', addr, error);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return parsed;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Configuration for the SeedBootstrapService
|
|
148
|
-
*/
|
|
149
|
-
export interface SeedBootstrapConfig {
|
|
150
|
-
/** Party ID for this cadre */
|
|
151
|
-
partyId: string;
|
|
152
|
-
/** Owner private key for signing seeds and peer authorizations (base64url) */
|
|
153
|
-
ownerPrivateKey?: string;
|
|
154
|
-
/** Owner public key (base64url) - derived from private key if not provided */
|
|
155
|
-
ownerPublicKey?: string;
|
|
156
|
-
/**
|
|
157
|
-
* Optional async resolver returning the multiaddrs to embed in invites.
|
|
158
|
-
* When unset, `libp2pNode.getMultiaddrs()` is used. Hosts behind NAT supply
|
|
159
|
-
* this (via `@serfab/cadre-host`'s NatService) to substitute the host's
|
|
160
|
-
* DDNS hostname and externally-mapped port.
|
|
161
|
-
*/
|
|
162
|
-
inviteAddressResolver?: () => Promise<string[]>;
|
|
163
|
-
/**
|
|
164
|
-
* Trust anchor for incoming seeds. Decides whether a signature-verified
|
|
165
|
-
* `signerKey` should be trusted, against the receiver's anchored owner
|
|
166
|
-
* keys (NOT the seed body). Defaults to `anchoredTrustPolicy()`, which
|
|
167
|
-
* rejects any signer not already in {@link trustedOwners}. An enrollment
|
|
168
|
-
* caller can pass a per-seed override to `applySeed` instead.
|
|
169
|
-
*
|
|
170
|
-
* A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
|
|
171
|
-
* — that is the only seam the inbound libp2p seed-protocol handler can use,
|
|
172
|
-
* since a network-delivered seed has no per-call override.
|
|
173
|
-
*/
|
|
174
|
-
trustPolicy?: SeedTrustPolicy;
|
|
175
|
-
/**
|
|
176
|
-
* The node-local, NON-replicated trusted-owner anchor. Supplies
|
|
177
|
-
* `SeedTrustContext.knownOwnerKeys` for every {@link applySeed}, and receives
|
|
178
|
-
* a key accepted via a pin/TOFU (see `SeedTrustDecision.anchorAs`).
|
|
179
|
-
*
|
|
180
|
-
* Deliberately NOT `ControlDatabase.getOwnerKeys()`: the replicated
|
|
181
|
-
* `OwnerKey` table is pollutable — any connecting node can genesis-insert its
|
|
182
|
-
* own key and let it replicate — so a seed signed by a stranger's self-issued
|
|
183
|
-
* owner key would pass a table-anchored check. A `CadreNode` passes its
|
|
184
|
-
* `getTrustedOwnerStore()` here. Unset (e.g. a directly-constructed service in
|
|
185
|
-
* a test) means an EMPTY anchor: only a pinned/TOFU policy can accept a seed.
|
|
186
|
-
*/
|
|
187
|
-
trustedOwners?: TrustedOwnerStore;
|
|
188
|
-
/**
|
|
189
|
-
* Time the inbound seed handler waits for the seed frame before aborting the
|
|
190
|
-
* read (ms). Defaults to {@link DEFAULT_SEED_READ_TIMEOUT_MS}. Bounds a
|
|
191
|
-
* buggy/compromised own-cadre node that opens a stream and never half-closes.
|
|
192
|
-
*/
|
|
193
|
-
seedReadTimeoutMs?: number;
|
|
194
|
-
/**
|
|
195
|
-
* Cap on concurrent inbound seed streams (defaults to
|
|
196
|
-
* {@link DEFAULT_MAX_CONCURRENT_SEEDS}). Over the cap, a non-accepting ack is
|
|
197
|
-
* returned without applying any seed.
|
|
198
|
-
*/
|
|
199
|
-
maxConcurrentSeeds?: number;
|
|
200
|
-
/**
|
|
201
|
-
* The host's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
|
|
202
|
-
* {@link seedDeliverTimeoutMs}'s default is derived. Unset means the declared default.
|
|
203
|
-
*/
|
|
204
|
-
linkRoundTripMs?: number;
|
|
205
|
-
/**
|
|
206
|
-
* Time {@link SeedBootstrapService.deliverSeed} waits for the whole exchange —
|
|
207
|
-
* dial, write, ack read — before aborting (ms). Bounds the SENDER against a seed
|
|
208
|
-
* target that accepts the stream and then never replies; the target is a
|
|
209
|
-
* not-yet-trusted node during onboarding, so this is the more exposed
|
|
210
|
-
* direction than the receiver knobs above.
|
|
211
|
-
*
|
|
212
|
-
* Defaults to `relayedRequestBudgetMs(linkRoundTripMs)` (`link-budget.ts`;
|
|
213
|
-
* default declaration): one dial that may need a relay, then one request and its answer.
|
|
214
|
-
* Delivery does not set `runOnLimitedConnection`, so it does not use a limited relayed
|
|
215
|
-
* connection today; the relayed-dial count is the upper bound on the dial it can use, the
|
|
216
|
-
* same choice `CadreNode.controlDialBudget` makes for every address. It holds only link work
|
|
217
|
-
* because the receiver acks before its owner dials.
|
|
218
|
-
*
|
|
219
|
-
* NOTE: no transfer allowance — a seed is a peer list of a few KB, and `MAX_SEED_SIZE` (1 MiB)
|
|
220
|
-
* is a defensive cap. If seeds ever grow toward that cap, add an allowance the way
|
|
221
|
-
* `PUSH_TRANSFER_ALLOWANCE_MS` does.
|
|
222
|
-
*/
|
|
223
|
-
seedDeliverTimeoutMs?: number;
|
|
224
|
-
/**
|
|
225
|
-
* Time limits for each peer this service dials from a list of addresses —
|
|
226
|
-
* {@link SeedBootstrapService.applySeed}'s owner dials and
|
|
227
|
-
* {@link SeedBootstrapService.dialInvite} — per address and per peer (see
|
|
228
|
-
* `peer-dial.ts`). Defaults to {@link DEFAULT_PEER_DIAL_BUDGET}; a `CadreNode`
|
|
229
|
-
* passes its `network.controlCohort` limits.
|
|
230
|
-
*/
|
|
231
|
-
dialBudget?: PeerDialBudget;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Event callbacks for seed-related events
|
|
236
|
-
*/
|
|
237
|
-
export interface SeedEventCallbacks {
|
|
238
|
-
/** Called when a seed is received via the protocol */
|
|
239
|
-
onSeedReceived?: (partyId: string, peerId: string) => void;
|
|
240
|
-
/**
|
|
241
|
-
* Called when a seed is successfully applied.
|
|
242
|
-
*
|
|
243
|
-
* `seed` is the applied seed itself: the inbound protocol handler applies it
|
|
244
|
-
* INSIDE the service, so this callback is the only seam through which a
|
|
245
|
-
* `CadreNode` sees a wire-delivered seed's contents — which it needs to
|
|
246
|
-
* retain the owner-flagged peers as cold-start bootstrap dial targets.
|
|
247
|
-
*/
|
|
248
|
-
onSeedApplied?: (partyId: string, peersAdded: number, seed: ControlNetworkSeed) => void;
|
|
249
|
-
/** Called when seed application fails */
|
|
250
|
-
onSeedError?: (partyId: string, error: string) => void;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* SeedBootstrapService handles control network seed generation and delivery.
|
|
255
|
-
*
|
|
256
|
-
* Seeds solve the cold-start problem: new nodes need control data to validate
|
|
257
|
-
* connections, but can't get data without connecting first. Seeds pre-populate
|
|
258
|
-
* the new node's cache with peer information.
|
|
259
|
-
*/
|
|
260
|
-
export class SeedBootstrapService {
|
|
261
|
-
private readonly config: SeedBootstrapConfig;
|
|
262
|
-
private libp2pNode: Libp2p | null = null;
|
|
263
|
-
private controlDatabase: ControlDatabase | null = null;
|
|
264
|
-
private readonly ownerPublicKey: string | null;
|
|
265
|
-
private readonly trustPolicy: SeedTrustPolicy;
|
|
266
|
-
private readonly seedReadTimeoutMs: number;
|
|
267
|
-
private readonly maxConcurrentSeeds: number;
|
|
268
|
-
private readonly seedDeliverTimeoutMs: number;
|
|
269
|
-
private readonly dialBudget: PeerDialBudget;
|
|
270
|
-
/** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
|
|
271
|
-
private activeStreams = 0;
|
|
272
|
-
private eventCallbacks: SeedEventCallbacks = {};
|
|
273
|
-
|
|
274
|
-
constructor(config: SeedBootstrapConfig) {
|
|
275
|
-
this.config = config;
|
|
276
|
-
this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
|
|
277
|
-
this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
|
|
278
|
-
this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
|
|
279
|
-
this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
|
|
280
|
-
this.dialBudget = config.dialBudget ?? DEFAULT_PEER_DIAL_BUDGET;
|
|
281
|
-
|
|
282
|
-
// Derive public key from private key if not provided
|
|
283
|
-
if (config.ownerPrivateKey && !config.ownerPublicKey) {
|
|
284
|
-
this.ownerPublicKey = getPublicKey(
|
|
285
|
-
config.ownerPrivateKey,
|
|
286
|
-
'ed25519',
|
|
287
|
-
'base64url',
|
|
288
|
-
'base64url'
|
|
289
|
-
) as string;
|
|
290
|
-
} else {
|
|
291
|
-
this.ownerPublicKey = config.ownerPublicKey ?? null;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
log('SeedBootstrapService created for party: %s', config.partyId);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Set event callbacks for seed-related events.
|
|
299
|
-
* Used by CadreNode to emit events.
|
|
300
|
-
*/
|
|
301
|
-
setEventCallbacks(callbacks: SeedEventCallbacks): void {
|
|
302
|
-
this.eventCallbacks = callbacks;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Whether this service holds an owner private key, i.e. can produce the
|
|
307
|
-
* owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
|
|
308
|
-
* and re-authorizations. A seed-listener-only service (`enableSeedListener`,
|
|
309
|
-
* no owner key) returns false: it can receive/apply seeds but cannot author
|
|
310
|
-
* or re-issue owner writes. Used by the write-while-alone re-replication
|
|
311
|
-
* drain to skip owner work on a non-owner node.
|
|
312
|
-
*/
|
|
313
|
-
canAuthorize(): boolean {
|
|
314
|
-
return !!this.config.ownerPrivateKey;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Initialize the service with libp2p node and control database.
|
|
319
|
-
*
|
|
320
|
-
* `registerHandler` (default true) gates registration of the shared inbound
|
|
321
|
-
* `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
|
|
322
|
-
* (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
|
|
323
|
-
* it on. The throwaway temp services CadreNode builds in `applySeed` /
|
|
324
|
-
* `dialInvite` pass `false`: they only need the stored `libp2pNode` /
|
|
325
|
-
* `controlDatabase` for dialing and known-key lookup, and must NOT bind a
|
|
326
|
-
* discarded closure to the shared node (a handler leak, and a second
|
|
327
|
-
* `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
|
|
328
|
-
*
|
|
329
|
-
* Rejects when libp2p refuses the registration; the node and database are
|
|
330
|
-
* kept only once the handler is in place, so a failed service holds nothing
|
|
331
|
-
* for {@link shutdown} to unhandle.
|
|
332
|
-
*/
|
|
333
|
-
async initialize(
|
|
334
|
-
libp2pNode: Libp2p,
|
|
335
|
-
controlDatabase: ControlDatabase,
|
|
336
|
-
options?: { registerHandler?: boolean }
|
|
337
|
-
): Promise<void> {
|
|
338
|
-
// Register the seed protocol handler unless the caller opted out (temp services).
|
|
339
|
-
if (options?.registerHandler ?? true) {
|
|
340
|
-
await this.registerProtocolHandler(libp2pNode);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
this.libp2pNode = libp2pNode;
|
|
344
|
-
this.controlDatabase = controlDatabase;
|
|
345
|
-
log('SeedBootstrapService initialized');
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Authorize a new peer to join the cadre.
|
|
350
|
-
* Signs a membership voucher with the owner key and inserts into CadrePeer table.
|
|
351
|
-
*
|
|
352
|
-
* The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
|
|
353
|
-
* caller-supplied key, the binding is enforced by construction — `PublicKey` is
|
|
354
|
-
* DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
|
|
355
|
-
* `PublicKey`, and such a row can never be self-updated (it has no key to
|
|
356
|
-
* verify against), which is correct. The row is inserted with a fresh
|
|
357
|
-
* `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
|
|
358
|
-
* the peer's self-signature, so the peer must self-publish (see
|
|
359
|
-
* {@link CadreNode.registerSelf}) before the row resolves.
|
|
360
|
-
*/
|
|
361
|
-
async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
|
|
362
|
-
const { peerId, multiaddrs } = options;
|
|
363
|
-
log('Authorizing peer: %s', peerId);
|
|
364
|
-
// CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
|
|
365
|
-
const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
|
|
366
|
-
await this.insertCadrePeerRow({
|
|
367
|
-
peerId,
|
|
368
|
-
publicKey: ed25519PublicKeyB64FromPeerId(peerId),
|
|
369
|
-
multiaddr: multiaddrStr,
|
|
370
|
-
updatedAt: Date.now(),
|
|
371
|
-
sig: null,
|
|
372
|
-
});
|
|
373
|
-
log('Peer %s authorized successfully', peerId);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Owner-signed INSERT of this node's OWN self-signed address record.
|
|
378
|
-
*
|
|
379
|
-
* Used by {@link CadreNode.registerSelf} when the node is not yet a member and
|
|
380
|
-
* is its own owner (it holds the owner key): the row is owner-signed
|
|
381
|
-
* (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
|
|
382
|
-
* immediately without a follow-up self-update.
|
|
383
|
-
*
|
|
384
|
-
* @returns `true` when this call seated the row, `false` when a concurrent writer
|
|
385
|
-
* (e.g. an {@link authorizePeer} of this node's own id) had already seated it —
|
|
386
|
-
* in which case the row in the database is NOT this record and carries whatever
|
|
387
|
-
* `Sig` that writer had, so the caller must self-update to publish its signature.
|
|
388
|
-
*/
|
|
389
|
-
async insertSelfPeerRecord(record: PeerAddressRecord): Promise<boolean> {
|
|
390
|
-
return await this.insertCadrePeerRow({
|
|
391
|
-
peerId: record.peerId,
|
|
392
|
-
publicKey: record.publicKey,
|
|
393
|
-
multiaddr: record.addrs.join(','),
|
|
394
|
-
updatedAt: record.updatedAt,
|
|
395
|
-
sig: record.sig,
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
|
|
401
|
-
* precondition; the stamp mint, voucher signature, write lock, existence check and
|
|
402
|
-
* membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
|
|
403
|
-
* the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
|
|
404
|
-
* method: the write is what admits the peer's traffic.
|
|
405
|
-
*
|
|
406
|
-
* The DB check precedes the owner-key check here, matching the order this method has
|
|
407
|
-
* always surfaced them (the owner-key error used to come out of the signer, after the
|
|
408
|
-
* DB check).
|
|
409
|
-
*
|
|
410
|
-
* @returns `true` when this call performed the INSERT, `false` when the row was
|
|
411
|
-
* already seated by a concurrent writer. The loser needs to know: an authorize seats
|
|
412
|
-
* a row with a null `Sig`, so a self-publish that lost the race must fall through to
|
|
413
|
-
* a self-update or its record never lands.
|
|
414
|
-
*/
|
|
415
|
-
private async insertCadrePeerRow(row: {
|
|
416
|
-
peerId: string;
|
|
417
|
-
publicKey: string | null;
|
|
418
|
-
multiaddr: string;
|
|
419
|
-
updatedAt: number;
|
|
420
|
-
sig: string | null;
|
|
421
|
-
}): Promise<boolean> {
|
|
422
|
-
if (!this.controlDatabase) {
|
|
423
|
-
throw new Error('Control database not initialized');
|
|
424
|
-
}
|
|
425
|
-
const ownerKey = this.requireOwnerPublicKey();
|
|
426
|
-
return await this.controlDatabase.insertCadrePeer(
|
|
427
|
-
row, ownerKey, message => this.signMessageBytes(message),
|
|
428
|
-
);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/**
|
|
432
|
-
* Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
|
|
433
|
-
*
|
|
434
|
-
* Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
|
|
435
|
-
* row is owner-signed (satisfying `DeviceToken.AuthorizedInsert` via the
|
|
436
|
-
* 'add'-tagged digest, {@link deviceTokenAddDigest}) AND carries the peer's
|
|
437
|
-
* own self-`Sig` over the token payload. The owner signature covers the WHOLE row
|
|
438
|
-
* (every column, ending in a freshly minted single-use `StampId`) — but covering the
|
|
439
|
-
* token contents is not the same as vouching them: the peer's `Sig` (verified at
|
|
440
|
-
* resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
|
|
441
|
-
* resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
|
|
442
|
-
* when the node is its own owner.
|
|
443
|
-
*
|
|
444
|
-
* The stamp is per-INSERT, so a re-register after a clear mints a fresh one and a
|
|
445
|
-
* fresh signature — unaffected by the cleared row's retired stamp
|
|
446
|
-
* (`DeviceToken.NotRevoked`).
|
|
447
|
-
*/
|
|
448
|
-
async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
|
|
449
|
-
const stampId = generateStampId(record.peerId);
|
|
450
|
-
const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
|
|
451
|
-
if (!this.controlDatabase) {
|
|
452
|
-
throw new Error('Control database not initialized');
|
|
453
|
-
}
|
|
454
|
-
await this.controlDatabase.execWrite(`
|
|
455
|
-
insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
|
|
456
|
-
with context OwnerKey = ?, Signature = ?
|
|
457
|
-
values (?, ?, ?, ?, ?, ?)
|
|
458
|
-
`, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
|
|
459
|
-
log('Device token inserted (owner-signed): %s', record.peerId);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
|
|
464
|
-
* invalidation). The `DeviceToken.AuthorizedDelete` constraint validates an owner
|
|
465
|
-
* signature over the 'remove'-tagged digest bound to the STORED row's
|
|
466
|
-
* (PeerId, StampId) — deliberately distinct from the insert digest, so a captured
|
|
467
|
-
* insert approval can never be replayed to clear a token. Like {@link removePeer}
|
|
468
|
-
* for `CadrePeer`, clearing a token requires the owner key.
|
|
469
|
-
*
|
|
470
|
-
* The delete and the `Revocation` tombstone retiring the row's `StampId` commit in
|
|
471
|
-
* ONE transaction — `DeviceToken.RevocationRecorded` refuses a bare delete, and
|
|
472
|
-
* without the tombstone the stamp would free up and the never-expiring insert
|
|
473
|
-
* approval (which the cleared device holds a copy of) would re-seat the token. The
|
|
474
|
-
* tombstone carries its OWN owner signature: retiring a stamp permanently forecloses
|
|
475
|
-
* that row, so it is an owner action in its own right.
|
|
476
|
-
*
|
|
477
|
-
* Both digests, the stamp read, and the transaction come from
|
|
478
|
-
* {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
|
|
479
|
-
* `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
|
|
480
|
-
* owner-key precondition; a no-op on an already-absent row is the shared body's
|
|
481
|
-
* behavior, and unlike {@link removePeer} nothing here rides on it.
|
|
482
|
-
*/
|
|
483
|
-
async deleteDeviceToken(peerId: string): Promise<void> {
|
|
484
|
-
// Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
|
|
485
|
-
// owner-key error rather than a silent no-op on an absent row.
|
|
486
|
-
const ownerKey = this.requireOwnerPublicKey();
|
|
487
|
-
if (!this.controlDatabase) {
|
|
488
|
-
throw new Error('Control database not initialized');
|
|
489
|
-
}
|
|
490
|
-
await this.controlDatabase.deleteDeviceToken(
|
|
491
|
-
peerId, ownerKey, message => this.signMessageBytes(message),
|
|
492
|
-
);
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/**
|
|
496
|
-
* Return the configured owner private key, or throw if none is set. The single
|
|
497
|
-
* precondition gate for every owner-signed write. {@link removePeer} /
|
|
498
|
-
* {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
|
|
499
|
-
* they call this up front — otherwise a keyless service would either surface the
|
|
500
|
-
* wrong "Control database not initialized" error or, worse, silently no-op when the
|
|
501
|
-
* target row is absent (the early `stampId === null` return) instead of rejecting.
|
|
502
|
-
*/
|
|
503
|
-
private requireOwnerPrivateKey(): string {
|
|
504
|
-
if (!this.config.ownerPrivateKey) {
|
|
505
|
-
throw new Error('Owner private key required to authorize peers');
|
|
506
|
-
}
|
|
507
|
-
return this.config.ownerPrivateKey;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/**
|
|
511
|
-
* The owner PUBLIC key that rides in every owner-signed write's context, non-null.
|
|
512
|
-
*
|
|
513
|
-
* The field is nullable because a read-only service carries no owner key at all, but the
|
|
514
|
-
* constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
|
|
515
|
-
* {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
|
|
516
|
-
* unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
|
|
517
|
-
* their first line and get the owner-key precondition for free.
|
|
518
|
-
*/
|
|
519
|
-
private requireOwnerPublicKey(): string {
|
|
520
|
-
this.requireOwnerPrivateKey();
|
|
521
|
-
if (!this.ownerPublicKey) {
|
|
522
|
-
throw new Error('Owner public key required to authorize peers');
|
|
523
|
-
}
|
|
524
|
-
return this.ownerPublicKey;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Sign a base64url digest with the owner key (ed25519). The single place the
|
|
529
|
-
* owner private key is applied; callers pass the canonical domain-tagged digest for
|
|
530
|
-
* the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
|
|
531
|
-
* {@link signMessageBytes}. Throws if no owner key is set.
|
|
532
|
-
*/
|
|
533
|
-
private signDigest(digestB64url: string): string {
|
|
534
|
-
return sign(
|
|
535
|
-
digestB64url,
|
|
536
|
-
this.requireOwnerPrivateKey(),
|
|
537
|
-
'ed25519',
|
|
538
|
-
'base64url',
|
|
539
|
-
'base64url',
|
|
540
|
-
'base64url'
|
|
541
|
-
) as string;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
* Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
|
|
546
|
-
* takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
|
|
547
|
-
* same signed bytes (`sign` decodes its base64url input), so a signature minted here
|
|
548
|
-
* satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
|
|
549
|
-
* `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
|
|
550
|
-
*/
|
|
551
|
-
private signMessageBytes(message: Uint8Array): string {
|
|
552
|
-
return this.signDigest(uint8ArrayToString(message, 'base64url'));
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
/**
|
|
556
|
-
* Remove a peer from the cadre by owner signature.
|
|
557
|
-
*
|
|
558
|
-
* The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
|
|
559
|
-
* signature over the DISTINCT 'remove'-tagged digest
|
|
560
|
-
* `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
|
|
561
|
-
* key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
|
|
562
|
-
* never be replayed to delete.
|
|
563
|
-
*
|
|
564
|
-
* The delete and the `Revocation` tombstone retiring the row's `StampId` commit in ONE
|
|
565
|
-
* transaction — `CadrePeer.RevocationRecorded` refuses a bare delete, and without the
|
|
566
|
-
* tombstone the stamp would free up and the original admission approval (which never
|
|
567
|
-
* expires, and which the removed peer holds a copy of) would re-seat the row.
|
|
568
|
-
*
|
|
569
|
-
* The tombstone is separately owner-signed (satisfying `Revocation.Authorized`):
|
|
570
|
-
* retiring a stamp evicts that peer party-wide and permanently forecloses re-admitting
|
|
571
|
-
* the row, so it is an owner action in its own right, not a side effect the delete's
|
|
572
|
-
* signature covers.
|
|
573
|
-
*
|
|
574
|
-
* Both digests, the stamp read, the transaction, and the post-commit membership notify
|
|
575
|
-
* come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
|
|
576
|
-
* `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
|
|
577
|
-
* owner-key precondition and the absent-row gate the notify depends on (below).
|
|
578
|
-
*/
|
|
579
|
-
async removePeer(peerId: string): Promise<void> {
|
|
580
|
-
// Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
|
|
581
|
-
// remove digest, and this precedence (owner key, then control DB) is what the
|
|
582
|
-
// unit contract asserts.
|
|
583
|
-
const ownerKey = this.requireOwnerPublicKey();
|
|
584
|
-
if (!this.controlDatabase) {
|
|
585
|
-
throw new Error('Control database not initialized');
|
|
586
|
-
}
|
|
587
|
-
// This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
|
|
588
|
-
// internally: deleteCadrePeer's membership notify fires whenever its body resolves,
|
|
589
|
-
// with no idea whether the body wrote anything, so delegating an absent peer would
|
|
590
|
-
// fire a spurious membership notification.
|
|
591
|
-
// NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
|
|
592
|
-
// between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
|
|
593
|
-
// window only — the common "already absent" case is caught here.
|
|
594
|
-
const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
|
|
595
|
-
if (stampId === null) {
|
|
596
|
-
log('removePeer: no CadrePeer row for %s (already absent)', peerId);
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
log('Removing peer: %s', peerId);
|
|
601
|
-
|
|
602
|
-
await this.controlDatabase.deleteCadrePeer(
|
|
603
|
-
peerId, ownerKey, message => this.signMessageBytes(message),
|
|
604
|
-
);
|
|
605
|
-
|
|
606
|
-
log('Peer %s removed successfully (stamp retired)', peerId);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
|
|
611
|
-
* re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
|
|
612
|
-
* the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
|
|
613
|
-
* a membership row that committed local-only (its block's cluster ≤1 at insert) is
|
|
614
|
-
* pushed to the cohort once it grows.
|
|
615
|
-
*
|
|
616
|
-
* The stamp read, the voucher signature, the write lock and the membership notify are
|
|
617
|
-
* {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
|
|
618
|
-
* `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
|
|
619
|
-
*
|
|
620
|
-
* A no-op (no throw, no notify) when the row does not exist.
|
|
621
|
-
*
|
|
622
|
-
* @param peerId - the membership row to re-touch.
|
|
623
|
-
* @param updatedAt - the strictly-increasing freshness stamp to write.
|
|
624
|
-
* @throws if no owner private key is configured (a non-owner cannot
|
|
625
|
-
* re-sign another peer's row) or the control database is not initialized.
|
|
626
|
-
*/
|
|
627
|
-
async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
|
|
628
|
-
// Fail fast on a keyless service before any DB read (see removePeer): a non-owner
|
|
629
|
-
// cannot re-sign the voucher, and must not silently no-op on an absent row.
|
|
630
|
-
const ownerKey = this.requireOwnerPublicKey();
|
|
631
|
-
if (!this.controlDatabase) {
|
|
632
|
-
throw new Error('Control database not initialized');
|
|
633
|
-
}
|
|
634
|
-
const retouched = await this.controlDatabase.reauthorizeCadrePeer(
|
|
635
|
-
peerId, updatedAt, ownerKey, message => this.signMessageBytes(message),
|
|
636
|
-
);
|
|
637
|
-
if (!retouched) {
|
|
638
|
-
log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
639
|
-
return;
|
|
640
|
-
}
|
|
641
|
-
log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
/**
|
|
645
|
-
* Owner re-issue of a batch of `Revocation` tombstones: bump each row's
|
|
646
|
-
* `ReissuedAt` so the storage layer re-broadcasts a tombstone that committed
|
|
647
|
-
* while the node was alone. The delete-while-alone counterpart of
|
|
648
|
-
* {@link reauthorizePeer} — a removed row cannot be re-touched (it is gone),
|
|
649
|
-
* but its tombstone can, and every membership read treats a retired stamp as
|
|
650
|
-
* absent.
|
|
651
|
-
*
|
|
652
|
-
* The signatures, the single transaction, and the strictly-monotonic
|
|
653
|
-
* `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
|
|
654
|
-
* stays here is the owner-key precondition.
|
|
655
|
-
*
|
|
656
|
-
* @returns how many tombstones were re-issued (`rows.length` on success).
|
|
657
|
-
* @throws if no owner private key is configured or the control database is not
|
|
658
|
-
* initialized.
|
|
659
|
-
*/
|
|
660
|
-
async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number): Promise<number> {
|
|
661
|
-
// Fail fast on a keyless service before any DB work (see removePeer): a
|
|
662
|
-
// non-owner cannot sign the reissue digests.
|
|
663
|
-
const ownerKey = this.requireOwnerPublicKey();
|
|
664
|
-
if (!this.controlDatabase) {
|
|
665
|
-
throw new Error('Control database not initialized');
|
|
666
|
-
}
|
|
667
|
-
return this.controlDatabase.reissueRevocations(
|
|
668
|
-
rows, reissuedAt, ownerKey, message => this.signMessageBytes(message),
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/**
|
|
673
|
-
* Owner filing of the singleton `Revocation` ledger marker, so the table is never a
|
|
674
|
-
* never-written block that the storage layer re-consults on every read.
|
|
675
|
-
*
|
|
676
|
-
* The signature, the insert-if-absent guard and the `'already-open'` mapping are
|
|
677
|
-
* {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-key
|
|
678
|
-
* precondition.
|
|
679
|
-
*
|
|
680
|
-
* @throws if no owner private key is configured or the control database is not
|
|
681
|
-
* initialized.
|
|
682
|
-
*/
|
|
683
|
-
async openRevocationLedger(): Promise<RevocationLedgerOpenResult> {
|
|
684
|
-
// Fail fast on a keyless service before any DB work (see removePeer): a
|
|
685
|
-
// non-owner cannot sign the marker's append digest.
|
|
686
|
-
const ownerKey = this.requireOwnerPublicKey();
|
|
687
|
-
if (!this.controlDatabase) {
|
|
688
|
-
throw new Error('Control database not initialized');
|
|
689
|
-
}
|
|
690
|
-
return this.controlDatabase.openRevocationLedger(
|
|
691
|
-
ownerKey, message => this.signMessageBytes(message),
|
|
692
|
-
);
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
/**
|
|
696
|
-
* Create a seed from the current control network state.
|
|
697
|
-
* The seed contains peer information and is signed by an owner.
|
|
698
|
-
*/
|
|
699
|
-
async createSeed(): Promise<ControlNetworkSeed> {
|
|
700
|
-
if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
|
|
701
|
-
throw new Error('Owner key required to create seeds');
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
if (!this.controlDatabase || !this.libp2pNode) {
|
|
705
|
-
throw new Error('Service not initialized');
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
log('Creating seed for party: %s', this.config.partyId);
|
|
709
|
-
|
|
710
|
-
// Query all peers from the control database
|
|
711
|
-
const peers = await this.queryPeers();
|
|
712
|
-
|
|
713
|
-
// Create the seed data (without signature)
|
|
714
|
-
const seedData = {
|
|
715
|
-
partyId: this.config.partyId,
|
|
716
|
-
peers,
|
|
717
|
-
};
|
|
718
|
-
|
|
719
|
-
// Sign the seed over its canonical byte representation
|
|
720
|
-
const seedJson = canonicalSeedPayload(seedData);
|
|
721
|
-
const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
|
|
722
|
-
const signature = sign(
|
|
723
|
-
seedDigest,
|
|
724
|
-
this.config.ownerPrivateKey,
|
|
725
|
-
'ed25519',
|
|
726
|
-
'base64url',
|
|
727
|
-
'base64url',
|
|
728
|
-
'base64url'
|
|
729
|
-
) as string;
|
|
730
|
-
|
|
731
|
-
const seed: ControlNetworkSeed = {
|
|
732
|
-
...seedData,
|
|
733
|
-
signature,
|
|
734
|
-
signerKey: this.ownerPublicKey,
|
|
735
|
-
};
|
|
736
|
-
|
|
737
|
-
log('Created seed with %d peers', peers.length);
|
|
738
|
-
return seed;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
/**
|
|
742
|
-
* Apply a seed to populate the peer cache and enable connections.
|
|
743
|
-
*
|
|
744
|
-
* Validates the seed signature, then evaluates a trust anchor for the
|
|
745
|
-
* `signerKey` that does NOT come from the seed body: the receiver's
|
|
746
|
-
* node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
|
|
747
|
-
* augmented by pinned keys or TOFU via the configured/overriding
|
|
748
|
-
* `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
|
|
749
|
-
* own signer as an owner peer — no longer passes, and neither does one signed
|
|
750
|
-
* by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
|
|
751
|
-
*
|
|
752
|
-
* A key accepted via a pin/TOFU is persisted into the anchor (the policy says
|
|
753
|
-
* so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
|
|
754
|
-
* anchored without re-supplying the invite.
|
|
755
|
-
*
|
|
756
|
-
* @param seed - The seed to apply (already transport-decoded).
|
|
757
|
-
* @param options.trustPolicy - Per-call policy override (e.g. a
|
|
758
|
-
* `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
|
|
759
|
-
* service-configured default for this seed only.
|
|
760
|
-
*/
|
|
761
|
-
async applySeed(
|
|
762
|
-
seed: ControlNetworkSeed,
|
|
763
|
-
options?: { trustPolicy?: SeedTrustPolicy }
|
|
764
|
-
): Promise<ApplySeedResult> {
|
|
765
|
-
const merged = await this.verifyAndMergeSeed(seed, options);
|
|
766
|
-
if (!merged.success) {
|
|
767
|
-
return merged;
|
|
768
|
-
}
|
|
769
|
-
return { ...merged, ...await this.dialSeedOwners(seed) };
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* The first half of {@link applySeed}: check the signature and the signer's trust, then merge
|
|
774
|
-
* the seed's peer addresses into the peer store. Every rejection happens here, so this result is
|
|
775
|
-
* what the inbound handler acks with; the owner dials that follow cannot change it.
|
|
776
|
-
*/
|
|
777
|
-
private async verifyAndMergeSeed(
|
|
778
|
-
seed: ControlNetworkSeed,
|
|
779
|
-
options?: { trustPolicy?: SeedTrustPolicy }
|
|
780
|
-
): Promise<ApplySeedResult> {
|
|
781
|
-
if (!this.libp2pNode) {
|
|
782
|
-
return seedRejected('Service not initialized');
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// NOTE: `seed.partyId` is never checked against `config.partyId`. Nothing
|
|
786
|
-
// downstream reads it — trust is keyed on `signerKey` vs the anchor, and the
|
|
787
|
-
// anchor a stray-party seed could write into belongs to THIS party, which
|
|
788
|
-
// only a caller-supplied pin for that signer can reach. If applying a seed
|
|
789
|
-
// ever branches on its partyId (or the anchor becomes multi-party), reject a
|
|
790
|
-
// mismatch here instead.
|
|
791
|
-
log('Applying seed for party: %s', seed.partyId);
|
|
792
|
-
|
|
793
|
-
// Validate the seed signature
|
|
794
|
-
if (!this.validateSeedSignature(seed)) {
|
|
795
|
-
return seedRejected('Invalid seed signature');
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
// Evaluate the trust anchor for the signer key. The known-owner set comes
|
|
799
|
-
// from the receiver's NODE-LOCAL anchor — never from the seed itself, and
|
|
800
|
-
// never from the replicated OwnerKey table (a stranger can genesis-insert
|
|
801
|
-
// its own key there and let it replicate into every peer's copy). A node
|
|
802
|
-
// whose anchor was never seeded, with no policy override, sees an empty set
|
|
803
|
-
// and rejects.
|
|
804
|
-
const knownOwnerKeys = this.config.trustedOwners?.all() ?? new Set<string>();
|
|
805
|
-
const policy = options?.trustPolicy ?? this.trustPolicy;
|
|
806
|
-
const decision = await policy.evaluate({
|
|
807
|
-
partyId: seed.partyId,
|
|
808
|
-
signerKey: seed.signerKey,
|
|
809
|
-
knownOwnerKeys,
|
|
810
|
-
});
|
|
811
|
-
if (!decision.trusted) {
|
|
812
|
-
return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
|
|
813
|
-
}
|
|
814
|
-
await this.anchorAcceptedSigner(seed.signerKey, decision);
|
|
815
|
-
|
|
816
|
-
let peersAdded = 0;
|
|
817
|
-
|
|
818
|
-
// Add peers to the peer store
|
|
819
|
-
for (const peer of seed.peers) {
|
|
820
|
-
try {
|
|
821
|
-
// Import peer multiaddrs into the peer store
|
|
822
|
-
if (peer.multiaddrs.length > 0) {
|
|
823
|
-
const peerId = peerIdFromString(peer.peerId);
|
|
824
|
-
const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
|
|
825
|
-
|
|
826
|
-
await this.libp2pNode.peerStore.merge(peerId, {
|
|
827
|
-
multiaddrs: addrs
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
peersAdded++;
|
|
831
|
-
log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
|
|
832
|
-
}
|
|
833
|
-
} catch (error) {
|
|
834
|
-
log('Failed to add peer %s: %o', peer.peerId, error);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
log('Merged seed: %d peers added', peersAdded);
|
|
839
|
-
return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/**
|
|
843
|
-
* The second half of {@link applySeed}: dial the seed's owner peers to establish connections.
|
|
844
|
-
*
|
|
845
|
-
* Best-effort and COUNTED: an owner that is momentarily down leaves this node seeded but
|
|
846
|
-
* unconnected, which the caller can only see if the outcome is reported (see
|
|
847
|
-
* `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
|
|
848
|
-
* `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
|
|
849
|
-
* reconcile pass until the control database has siblings.
|
|
850
|
-
*
|
|
851
|
-
* The inbound handler runs this AFTER it has acked and closed the stream
|
|
852
|
-
* ({@link handleSeedStream}), because an unreachable owner can take `dialBudget.totalMs`.
|
|
853
|
-
*/
|
|
854
|
-
private async dialSeedOwners(seed: ControlNetworkSeed): Promise<OwnerDialCounts> {
|
|
855
|
-
const node = this.libp2pNode;
|
|
856
|
-
if (!node) {
|
|
857
|
-
log('Seed service shut down before its owner dials; none attempted');
|
|
858
|
-
return { ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
859
|
-
}
|
|
860
|
-
// `createSeed` projects every non-revoked CadrePeer row, so an owner applying
|
|
861
|
-
// a seed minted after it joined finds ITSELF in the owner list. Dialing self always
|
|
862
|
-
// throws, which would report a healthy owner as "seeded but stranded".
|
|
863
|
-
// Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
|
|
864
|
-
// and an undefined self simply matches nothing.
|
|
865
|
-
// Every one of an owner's addresses is a candidate, each on its own time limit
|
|
866
|
-
// (`dialPeerAddrs`), so an owner whose first address never answers neither
|
|
867
|
-
// stalls seed application nor goes undialed at its other addresses.
|
|
868
|
-
const selfPeerId = node.peerId?.toString();
|
|
869
|
-
let ownerDialsAttempted = 0;
|
|
870
|
-
let ownerDialsFailed = 0;
|
|
871
|
-
for (const peer of seed.peers.filter(p => p.isOwner)) {
|
|
872
|
-
if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
|
|
873
|
-
continue;
|
|
874
|
-
}
|
|
875
|
-
ownerDialsAttempted++;
|
|
876
|
-
try {
|
|
877
|
-
const addrs = parseDialAddrs(peer.multiaddrs);
|
|
878
|
-
|
|
879
|
-
log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
|
|
880
|
-
await dialPeerAddrs(node, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
|
|
881
|
-
} catch (error) {
|
|
882
|
-
// Counted even when the owner reaches us only through our own relay: this node is still
|
|
883
|
-
// not connected to it, and `ownerDialsFailed` is how the caller learns that.
|
|
884
|
-
ownerDialsFailed++;
|
|
885
|
-
if (error instanceof SelfRelayOnlyError) {
|
|
886
|
-
log('Owner peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
|
|
887
|
-
} else {
|
|
888
|
-
log('Failed to dial peer %s: %o', peer.peerId, error);
|
|
889
|
-
}
|
|
890
|
-
// Continue - not all peers need to be reachable
|
|
891
|
-
}
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
log('Dialed seed owners: %d/%d owner dial(s) failed', ownerDialsFailed, ownerDialsAttempted);
|
|
895
|
-
return { ownerDialsAttempted, ownerDialsFailed };
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
/**
|
|
899
|
-
* Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
|
|
900
|
-
* later seed from the same owner is anchored without re-supplying the invite
|
|
901
|
-
* or re-prompting. Only the policy decides this happens (`anchorAs` is unset
|
|
902
|
-
* when the key was already anchored, so a plain re-apply writes nothing) and
|
|
903
|
-
* `trust()` is idempotent, keeping the original provenance for a known key.
|
|
904
|
-
*
|
|
905
|
-
* Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
|
|
906
|
-
* in-memory anchor synchronously, so this seed and the rest of the session are
|
|
907
|
-
* unaffected — only durability across a restart is lost, and that is logged.
|
|
908
|
-
*
|
|
909
|
-
* NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
|
|
910
|
-
* unauthorized to authorized, which the write-driven membership-gate refresh
|
|
911
|
-
* (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. Every
|
|
912
|
-
* anchor mutation today rides seed application, and both seed paths refresh the
|
|
913
|
-
* gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`). If some
|
|
914
|
-
* future path anchors an owner OUTSIDE seed application, it owes the same
|
|
915
|
-
* `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
|
|
916
|
-
*/
|
|
917
|
-
private async anchorAcceptedSigner(signerKey: string, decision: SeedTrustDecision): Promise<void> {
|
|
918
|
-
if (!decision.anchorAs || !this.config.trustedOwners) {
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
921
|
-
try {
|
|
922
|
-
await this.config.trustedOwners.trust(signerKey, decision.anchorAs);
|
|
923
|
-
log('Anchored seed signer %s as %s', signerKey, decision.anchorAs);
|
|
924
|
-
} catch (error) {
|
|
925
|
-
log('Failed to persist accepted seed signer into the trusted-owner anchor: %o', error);
|
|
926
|
-
}
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
/**
|
|
930
|
-
* Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
|
|
931
|
-
*/
|
|
932
|
-
encodeSeed(seed: ControlNetworkSeed): string {
|
|
933
|
-
const json = JSON.stringify(seed);
|
|
934
|
-
return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
/**
|
|
938
|
-
* Decode a seed from base64url encoding.
|
|
939
|
-
*/
|
|
940
|
-
decodeSeed(encoded: string): ControlNetworkSeed {
|
|
941
|
-
const bytes = uint8ArrayFromString(encoded, 'base64url');
|
|
942
|
-
const json = new TextDecoder().decode(bytes);
|
|
943
|
-
return JSON.parse(json) as ControlNetworkSeed;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
/**
|
|
947
|
-
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
948
|
-
*
|
|
949
|
-
* Sender hardening: the whole exchange — dial, write, ack read — is bounded by
|
|
950
|
-
* {@link seedDeliverTimeoutMs}, and the ack is capped at {@link MAX_SEED_SIZE}.
|
|
951
|
-
* The target is a NOT-YET-TRUSTED node the instigator chose to dial during
|
|
952
|
-
* onboarding, so an unbounded read here is strictly more exposed than the
|
|
953
|
-
* membership-gated receiver paths: without the bound a target that accepts the
|
|
954
|
-
* stream and never replies parks this call forever, and one that streams
|
|
955
|
-
* arbitrary bytes as a fake ack exhausts memory.
|
|
956
|
-
*/
|
|
957
|
-
async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<SeedAckMessage> {
|
|
958
|
-
if (!this.libp2pNode) {
|
|
959
|
-
throw new Error('Service not initialized');
|
|
960
|
-
}
|
|
961
|
-
// Capture the node so the closure below needs no non-null assertion.
|
|
962
|
-
const node = this.libp2pNode;
|
|
963
|
-
const addr = multiaddr(targetMultiaddr);
|
|
964
|
-
|
|
965
|
-
log('Delivering seed to: %s', targetMultiaddr);
|
|
966
|
-
|
|
967
|
-
return await withDeadline(
|
|
968
|
-
this.seedDeliverTimeoutMs,
|
|
969
|
-
`Seed delivery to ${targetMultiaddr}`,
|
|
970
|
-
(signal) => this.sendSeed(node, addr, seed, signal),
|
|
971
|
-
);
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
/**
|
|
975
|
-
* Open one stream to the target, send the seed frame, half-close, and read the ack.
|
|
976
|
-
*
|
|
977
|
-
* `signal` is the deadline from {@link deliverSeed}: it goes to `dialProtocol` so
|
|
978
|
-
* a timeout during connect aborts the dial, and into {@link exchangeFrame} so a
|
|
979
|
-
* timeout after the stream is open resets it — releasing the otherwise unbounded
|
|
980
|
-
* ack-read.
|
|
981
|
-
*
|
|
982
|
-
* Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
|
|
983
|
-
* tiny frame over a relay, whereas a seed is up to 1MB and this delivery path
|
|
984
|
-
* does not dial relay addresses today. Changing that is a separate decision.
|
|
985
|
-
*/
|
|
986
|
-
private async sendSeed(
|
|
987
|
-
node: Libp2p,
|
|
988
|
-
addr: Multiaddr,
|
|
989
|
-
seed: ControlNetworkSeed,
|
|
990
|
-
signal: AbortSignal,
|
|
991
|
-
): Promise<SeedAckMessage> {
|
|
992
|
-
const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
|
|
993
|
-
|
|
994
|
-
const message: SeedMessage = {
|
|
995
|
-
partyId: seed.partyId,
|
|
996
|
-
peers: seed.peers,
|
|
997
|
-
signature: seed.signature,
|
|
998
|
-
signerKey: seed.signerKey,
|
|
999
|
-
};
|
|
1000
|
-
|
|
1001
|
-
const ack = await exchangeFrame(
|
|
1002
|
-
rawStream as unknown as ControlStream,
|
|
1003
|
-
signal,
|
|
1004
|
-
message,
|
|
1005
|
-
(stream) => this.readSeedAck(stream),
|
|
1006
|
-
'Seed delivery aborted by timeout',
|
|
1007
|
-
);
|
|
1008
|
-
|
|
1009
|
-
log('Seed delivery response: accepted=%s', ack.accepted);
|
|
1010
|
-
return ack;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
/**
|
|
1014
|
-
* Read the ack frame a delivery target writes back, bounded by
|
|
1015
|
-
* {@link seedDeliverTimeoutMs} and capped at {@link MAX_SEED_SIZE} — an
|
|
1016
|
-
* untrusted target must not be able to stream unlimited bytes as a fake ack.
|
|
1017
|
-
* Decoding runs inside {@link exchangeFrame}'s `try`, so a malformed or
|
|
1018
|
-
* non-JSON ack resets the stream rather than leaking it.
|
|
1019
|
-
*/
|
|
1020
|
-
private async readSeedAck(stream: ControlStream): Promise<SeedAckMessage> {
|
|
1021
|
-
const data = await readStreamToEnd(stream, {
|
|
1022
|
-
maxBytes: MAX_SEED_SIZE,
|
|
1023
|
-
timeoutMs: this.seedDeliverTimeoutMs,
|
|
1024
|
-
label: 'Seed ack',
|
|
1025
|
-
});
|
|
1026
|
-
const body = decodeLengthPrefixedFrame(data, MAX_SEED_SIZE);
|
|
1027
|
-
return JSON.parse(new TextDecoder().decode(body)) as SeedAckMessage;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
/**
|
|
1031
|
-
* Get this node's circuit relay address for inclusion in seeds.
|
|
1032
|
-
* Returns null if no relay address is available.
|
|
1033
|
-
*/
|
|
1034
|
-
async getRelayAddress(): Promise<string | null> {
|
|
1035
|
-
if (!this.libp2pNode) {
|
|
1036
|
-
return null;
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
const addrs = this.libp2pNode.getMultiaddrs();
|
|
1040
|
-
|
|
1041
|
-
// Find a circuit relay address
|
|
1042
|
-
const relayAddr = addrs.find(addr => addr.toString().includes('/p2p-circuit/'));
|
|
1043
|
-
|
|
1044
|
-
return relayAddr?.toString() ?? null;
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
/**
|
|
1048
|
-
* Validate a seed's signature.
|
|
1049
|
-
*/
|
|
1050
|
-
validateSeedSignature(seed: ControlNetworkSeed): boolean {
|
|
1051
|
-
try {
|
|
1052
|
-
// Reconstruct the signed bytes via the shared canonical payload builder so
|
|
1053
|
-
// verification is independent of key order. The payload is the fixed
|
|
1054
|
-
// `{ partyId, peers }` the producer emits.
|
|
1055
|
-
const seedJson = canonicalSeedPayload(seed);
|
|
1056
|
-
const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
|
|
1057
|
-
|
|
1058
|
-
return verify(
|
|
1059
|
-
seedDigest,
|
|
1060
|
-
seed.signature,
|
|
1061
|
-
seed.signerKey,
|
|
1062
|
-
'ed25519',
|
|
1063
|
-
'base64url',
|
|
1064
|
-
'base64url',
|
|
1065
|
-
'base64url'
|
|
1066
|
-
);
|
|
1067
|
-
} catch (error) {
|
|
1068
|
-
log('Seed signature validation failed: %o', error);
|
|
1069
|
-
return false;
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
/**
|
|
1074
|
-
* Query peers from the control database.
|
|
1075
|
-
*
|
|
1076
|
-
* Owner identity is sourced from the `OwnerKey` table, not from the
|
|
1077
|
-
* transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
|
|
1078
|
-
* multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
|
|
1079
|
-
* peer is an owner iff that derived key is in the `OwnerKey` set.
|
|
1080
|
-
* This makes any owner node markable — not just the local one — and ties
|
|
1081
|
-
* `isOwner` to the control table rather than to `peerId === self`.
|
|
1082
|
-
*
|
|
1083
|
-
* Read through {@link ControlDatabase.queryCadrePeers}, not a raw `CadrePeer`
|
|
1084
|
-
* select: that reader drops any row whose `StampId` is retired in
|
|
1085
|
-
* `CadreControl.Revocation`, so a removed member's addresses are never packed
|
|
1086
|
-
* into a seed and pushed into a joiner's peerstore (`applySeed` adds every
|
|
1087
|
-
* seed peer's addrs and dials the owner-flagged ones). A revoked peer is off
|
|
1088
|
-
* the addressable surface everywhere, and this is one of its exits.
|
|
1089
|
-
*
|
|
1090
|
-
* NOTE: this is the one owner lookup deliberately left on the REPLICATED
|
|
1091
|
-
* table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
|
|
1092
|
-
* — the receiver dials owner-flagged peers first — not a trust decision, and
|
|
1093
|
-
* the receiver re-derives real trust from its own anchor. So a polluted table
|
|
1094
|
-
* costs at most a wasted dial, while anchoring here would silently drop
|
|
1095
|
-
* legitimate co-owners this node never pinned. If `isOwner` ever gates
|
|
1096
|
-
* anything the receiver TRUSTS, move it to the anchor.
|
|
1097
|
-
*/
|
|
1098
|
-
private async queryPeers(): Promise<SeedPeer[]> {
|
|
1099
|
-
if (!this.controlDatabase) {
|
|
1100
|
-
return [];
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
const ownerKeys = await this.controlDatabase.getOwnerKeys();
|
|
1104
|
-
const rows = await this.controlDatabase.queryCadrePeers();
|
|
1105
|
-
|
|
1106
|
-
return rows.map(({ peerId, multiaddr }) => {
|
|
1107
|
-
// Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
|
|
1108
|
-
// unparsable id yields null and is treated as a non-owner rather than
|
|
1109
|
-
// failing the whole seed creation.
|
|
1110
|
-
const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
|
|
1111
|
-
const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
|
|
1112
|
-
|
|
1113
|
-
return {
|
|
1114
|
-
peerId,
|
|
1115
|
-
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
1116
|
-
isOwner,
|
|
1117
|
-
...(isOwner ? { publicKey: pubKeyB64 } : {}),
|
|
1118
|
-
};
|
|
1119
|
-
});
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
/**
|
|
1123
|
-
* Register the seed protocol handler. The inbound closure just delegates to
|
|
1124
|
-
* {@link handleSeedStream} — extracted as a method so it has a unit-test seam
|
|
1125
|
-
* (mirroring wake's `handleStream`) the inline closure never had.
|
|
1126
|
-
*/
|
|
1127
|
-
private async registerProtocolHandler(libp2pNode: Libp2p): Promise<void> {
|
|
1128
|
-
await libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
|
|
1129
|
-
const remotePeerId = (rawConnection as Connection).remotePeer.toString();
|
|
1130
|
-
await this.handleSeedStream(rawStream as ControlStream, remotePeerId);
|
|
1131
|
-
});
|
|
1132
|
-
|
|
1133
|
-
log('Registered seed protocol handler: %s', SEED_PROTOCOL);
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
/**
|
|
1137
|
-
* Read one inbound seed frame, verify and merge it, write the ack and close the
|
|
1138
|
-
* stream, then dial the seed's owners.
|
|
1139
|
-
*
|
|
1140
|
-
* The ack and the close come BEFORE the owner dials: an unreachable owner can hold
|
|
1141
|
-
* a dial for `dialBudget.totalMs` (
|
|
1142
|
-
* link time and does not belong inside the sender's delivery deadline. The close is
|
|
1143
|
-
* what releases the sender, which reads the ack to end-of-stream. The stream stays
|
|
1144
|
-
* counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
|
|
1145
|
-
* still bounds concurrent owner-dial phases.
|
|
1146
|
-
*
|
|
1147
|
-
* Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
|
|
1148
|
-
* {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
|
|
1149
|
-
* that never half-closes is aborted inside `readStreamToEnd`), and the existing
|
|
1150
|
-
* malformed/oversized-frame guard — all reported as a non-accepting
|
|
1151
|
-
* {@link SeedAckMessage} rather than a dropped/hung stream.
|
|
1152
|
-
*
|
|
1153
|
-
* NOTE: a receiver configured with an interactive trust-on-first-use policy asks a
|
|
1154
|
-
* human before acking, and that wait sits inside the sender's delivery deadline. If
|
|
1155
|
-
* TOFU is ever used on this wire path, ack "pending" or move the confirmation out of
|
|
1156
|
-
* the exchange.
|
|
1157
|
-
*/
|
|
1158
|
-
private async handleSeedStream(stream: ControlStream, remotePeerId: string): Promise<void> {
|
|
1159
|
-
log('Incoming seed delivery from: %s', remotePeerId);
|
|
1160
|
-
|
|
1161
|
-
if (this.activeStreams >= this.maxConcurrentSeeds) {
|
|
1162
|
-
log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
|
|
1163
|
-
await replyAndClose(stream, { accepted: false, reason: 'Too many concurrent seed deliveries' } satisfies SeedAckMessage, 'Seed');
|
|
1164
|
-
return;
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
this.activeStreams++;
|
|
1168
|
-
let acked = false;
|
|
1169
|
-
try {
|
|
1170
|
-
const seed = await this.readSeedFrame(stream);
|
|
1171
|
-
this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
|
|
1172
|
-
|
|
1173
|
-
const merged = await this.verifyAndMergeSeed(seed);
|
|
1174
|
-
acked = true;
|
|
1175
|
-
await replyAndClose(stream, { accepted: merged.success, reason: merged.error } satisfies SeedAckMessage, 'Seed');
|
|
1176
|
-
|
|
1177
|
-
if (merged.success) {
|
|
1178
|
-
await this.dialSeedOwners(seed);
|
|
1179
|
-
this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
|
|
1180
|
-
} else {
|
|
1181
|
-
this.eventCallbacks.onSeedError?.(seed.partyId, merged.error ?? 'Unknown error');
|
|
1182
|
-
}
|
|
1183
|
-
} catch (error) {
|
|
1184
|
-
log('Error handling seed delivery: %o', error);
|
|
1185
|
-
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1186
|
-
this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
|
|
1187
|
-
// Once acked the stream is closed, so a later failure has no reply to make.
|
|
1188
|
-
if (!acked) {
|
|
1189
|
-
await replyAndClose(stream, { accepted: false, reason: errorMessage } satisfies SeedAckMessage, 'Seed');
|
|
1190
|
-
}
|
|
1191
|
-
} finally {
|
|
1192
|
-
this.activeStreams--;
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
/** Read the inbound seed frame to EOF (bounded and size-capped) and decode it. */
|
|
1197
|
-
private async readSeedFrame(stream: ControlStream): Promise<ControlNetworkSeed> {
|
|
1198
|
-
const data = await readStreamToEnd(stream, {
|
|
1199
|
-
maxBytes: MAX_SEED_SIZE,
|
|
1200
|
-
timeoutMs: this.seedReadTimeoutMs,
|
|
1201
|
-
label: 'Seed',
|
|
1202
|
-
});
|
|
1203
|
-
const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data))) as SeedMessage;
|
|
1204
|
-
return {
|
|
1205
|
-
partyId: message.partyId,
|
|
1206
|
-
peers: message.peers,
|
|
1207
|
-
signature: message.signature,
|
|
1208
|
-
signerKey: message.signerKey,
|
|
1209
|
-
};
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
/**
|
|
1213
|
-
* Shutdown the service.
|
|
1214
|
-
*/
|
|
1215
|
-
async shutdown(): Promise<void> {
|
|
1216
|
-
if (this.libp2pNode) {
|
|
1217
|
-
await this.libp2pNode.unhandle(SEED_PROTOCOL);
|
|
1218
|
-
}
|
|
1219
|
-
this.libp2pNode = null;
|
|
1220
|
-
this.controlDatabase = null;
|
|
1221
|
-
log('SeedBootstrapService shutdown');
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
// ============================================================================
|
|
1225
|
-
// Helper Functions for Common Scenarios
|
|
1226
|
-
// ============================================================================
|
|
1227
|
-
|
|
1228
|
-
/**
|
|
1229
|
-
* Add a drone to the cadre (phone/server adds provider-hosted node).
|
|
1230
|
-
*
|
|
1231
|
-
* Use this when you've spawned a drone via provider API and received its
|
|
1232
|
-
* peer ID and multiaddrs. This method:
|
|
1233
|
-
* 1. Authorizes the drone peer
|
|
1234
|
-
* 2. Creates a seed including all current peers
|
|
1235
|
-
* 3. Returns the seed for sending to provider API
|
|
1236
|
-
*
|
|
1237
|
-
* Nothing here dials the drone or remembers its addresses beyond the unsigned
|
|
1238
|
-
* `CadrePeer` row, which no resolver accepts. `CadreNode.addDrone` is the entry
|
|
1239
|
-
* point that also retains them as a durable dial target, so the adder's
|
|
1240
|
-
* reconcile pass can open the connection — the drone cannot dial an owner that
|
|
1241
|
-
* does not listen. Call `CadreNode.reconcileControlCohort()` after delivering
|
|
1242
|
-
* the seed to dial at once.
|
|
1243
|
-
*
|
|
1244
|
-
* @param options - Drone peer info from provider API
|
|
1245
|
-
* @returns Seed and encoded seed for drone initialization
|
|
1246
|
-
*/
|
|
1247
|
-
async addDrone(options: AddDroneOptions): Promise<DroneInitResult> {
|
|
1248
|
-
const { dronePeerId, droneMultiaddrs } = options;
|
|
1249
|
-
|
|
1250
|
-
log('Adding drone: %s', dronePeerId);
|
|
1251
|
-
|
|
1252
|
-
// 1. Authorize the new drone peer
|
|
1253
|
-
await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs });
|
|
1254
|
-
|
|
1255
|
-
// 2. Create seed with current state
|
|
1256
|
-
const seed = await this.createSeed();
|
|
1257
|
-
|
|
1258
|
-
// 3. Encode for transport
|
|
1259
|
-
const encodedSeed = this.encodeSeed(seed);
|
|
1260
|
-
|
|
1261
|
-
log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
|
|
1262
|
-
|
|
1263
|
-
return { seed, encodedSeed };
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
/**
|
|
1267
|
-
* Create an invite for a phone to join the cadre.
|
|
1268
|
-
*
|
|
1269
|
-
* Use this when a server (public IP) wants to invite a phone (NAT'd).
|
|
1270
|
-
* The invite is shared out-of-band (QR code, link, etc.) and contains
|
|
1271
|
-
* the server's address so the phone can dial in.
|
|
1272
|
-
*
|
|
1273
|
-
* @param token - Optional invite token for validation
|
|
1274
|
-
* @param expiresIn - Optional expiration time in milliseconds
|
|
1275
|
-
* @returns Invite and encoded invite for sharing
|
|
1276
|
-
*/
|
|
1277
|
-
async createInvite(token?: string, expiresIn?: number): Promise<InviteResult> {
|
|
1278
|
-
if (!this.libp2pNode) {
|
|
1279
|
-
throw new Error('Service not initialized');
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
log('Creating invite for phone');
|
|
1283
|
-
|
|
1284
|
-
// Get this node's dialable addresses. When an inviteAddressResolver is
|
|
1285
|
-
// configured (typically by `@serfab/cadre-host`'s NatService), it takes
|
|
1286
|
-
// priority — it may substitute a DDNS hostname and externally-mapped
|
|
1287
|
-
// port for the raw LAN multiaddrs libp2p reports.
|
|
1288
|
-
let ownerAddrs: string[];
|
|
1289
|
-
if (this.config.inviteAddressResolver) {
|
|
1290
|
-
try {
|
|
1291
|
-
ownerAddrs = await this.config.inviteAddressResolver();
|
|
1292
|
-
} catch (err) {
|
|
1293
|
-
log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
|
|
1294
|
-
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
1295
|
-
}
|
|
1296
|
-
} else {
|
|
1297
|
-
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
// Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
|
|
1301
|
-
// the trusted owner set before applying any seed.
|
|
1302
|
-
//
|
|
1303
|
-
// Sourced ONLY from this node's own anchor, never from the replicated
|
|
1304
|
-
// OwnerKey table: the invitee anchors whatever arrives here
|
|
1305
|
-
// (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
|
|
1306
|
-
// pollutable table would let a stranger's genesis-inserted key ride an
|
|
1307
|
-
// otherwise-legitimate invite straight into the new node's anchor —
|
|
1308
|
-
// poisoning the very store this whole trust chain rests on. No anchor wired
|
|
1309
|
-
// (a directly-constructed service) means no pins to hand out: an invite
|
|
1310
|
-
// without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
|
|
1311
|
-
// table-sourced one silently hands it an unanchored key.
|
|
1312
|
-
const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
|
|
1313
|
-
|
|
1314
|
-
const now = Date.now();
|
|
1315
|
-
const invite: CadreInvite = {
|
|
1316
|
-
partyId: this.config.partyId,
|
|
1317
|
-
ownerAddrs,
|
|
1318
|
-
ownerKeys: ownerKeys.length ? ownerKeys : undefined,
|
|
1319
|
-
token,
|
|
1320
|
-
createdAt: now,
|
|
1321
|
-
expiresAt: expiresIn ? now + expiresIn : undefined,
|
|
1322
|
-
};
|
|
1323
|
-
|
|
1324
|
-
const encodedInvite = this.encodeInvite(invite);
|
|
1325
|
-
|
|
1326
|
-
log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
|
|
1327
|
-
|
|
1328
|
-
return { invite, encodedInvite };
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
/**
|
|
1332
|
-
* Accept a phone connection using an invite.
|
|
1333
|
-
*
|
|
1334
|
-
* Use this when a phone dials in with an invite token. This method:
|
|
1335
|
-
* 1. Validates the token if provided
|
|
1336
|
-
* 2. Authorizes the phone peer
|
|
1337
|
-
*
|
|
1338
|
-
* After this, the phone can sync the control database normally.
|
|
1339
|
-
*
|
|
1340
|
-
* @param options - Phone peer info and invite token
|
|
1341
|
-
* @param issuedInvite - The original invite for validation
|
|
1342
|
-
*/
|
|
1343
|
-
async acceptPhone(options: AddPhoneOptions, issuedInvite?: CadreInvite): Promise<void> {
|
|
1344
|
-
const { phonePeerId, token } = options;
|
|
1345
|
-
|
|
1346
|
-
log('Accepting phone: %s', phonePeerId);
|
|
1347
|
-
|
|
1348
|
-
// Validate token if invite provided
|
|
1349
|
-
if (issuedInvite) {
|
|
1350
|
-
if (issuedInvite.token && issuedInvite.token !== token) {
|
|
1351
|
-
throw new Error('Invalid invite token');
|
|
1352
|
-
}
|
|
1353
|
-
if (issuedInvite.expiresAt && Date.now() > issuedInvite.expiresAt) {
|
|
1354
|
-
throw new Error('Invite has expired');
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
// Authorize the phone peer (no multiaddrs - phone is NAT'd)
|
|
1359
|
-
await this.authorizePeer({ peerId: phonePeerId });
|
|
1360
|
-
|
|
1361
|
-
log('Phone %s accepted and authorized', phonePeerId);
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
/**
|
|
1365
|
-
* Add a phone to the cadre with relay support.
|
|
1366
|
-
*
|
|
1367
|
-
* Use this when both nodes are NAT'd (phone-to-phone). This method:
|
|
1368
|
-
* 1. Authorizes the new phone peer
|
|
1369
|
-
* 2. Creates a seed with relay addresses for dialing
|
|
1370
|
-
*
|
|
1371
|
-
* @param phonePeerId - Peer ID of the new phone
|
|
1372
|
-
* @returns Seed with relay addresses for out-of-band delivery
|
|
1373
|
-
*/
|
|
1374
|
-
async addPhoneWithRelay(phonePeerId: string): Promise<DroneInitResult> {
|
|
1375
|
-
log('Adding phone with relay: %s', phonePeerId);
|
|
1376
|
-
|
|
1377
|
-
// 1. Authorize the new phone peer (no multiaddrs - NAT'd)
|
|
1378
|
-
await this.authorizePeer({ peerId: phonePeerId });
|
|
1379
|
-
|
|
1380
|
-
// 2. Get relay address for this node
|
|
1381
|
-
const relayAddr = await this.getRelayAddress();
|
|
1382
|
-
|
|
1383
|
-
// 3. Create seed - will include our relay address if available
|
|
1384
|
-
const seed = await this.createSeed();
|
|
1385
|
-
|
|
1386
|
-
// If we have a relay address, make sure it's in our peer entry
|
|
1387
|
-
if (relayAddr && this.libp2pNode) {
|
|
1388
|
-
const ourPeerId = this.libp2pNode.peerId.toString();
|
|
1389
|
-
const ourPeer = seed.peers.find(p => p.peerId === ourPeerId);
|
|
1390
|
-
if (ourPeer && !ourPeer.multiaddrs.includes(relayAddr)) {
|
|
1391
|
-
ourPeer.multiaddrs.push(relayAddr);
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
const encodedSeed = this.encodeSeed(seed);
|
|
1396
|
-
|
|
1397
|
-
log('Phone %s added with relay, seed created', phonePeerId);
|
|
1398
|
-
|
|
1399
|
-
return { seed, encodedSeed };
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
/**
|
|
1403
|
-
* Encode an invite for out-of-band delivery.
|
|
1404
|
-
*/
|
|
1405
|
-
encodeInvite(invite: CadreInvite): string {
|
|
1406
|
-
const json = JSON.stringify(invite);
|
|
1407
|
-
return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
/**
|
|
1411
|
-
* Decode an invite from base64url encoding.
|
|
1412
|
-
*/
|
|
1413
|
-
decodeInvite(encoded: string): CadreInvite {
|
|
1414
|
-
const bytes = uint8ArrayFromString(encoded, 'base64url');
|
|
1415
|
-
const json = new TextDecoder().decode(bytes);
|
|
1416
|
-
return JSON.parse(json) as CadreInvite;
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
/**
|
|
1420
|
-
* Dial an owner from an invite.
|
|
1421
|
-
* Use this on a phone after receiving an invite to connect to the owner.
|
|
1422
|
-
*
|
|
1423
|
-
* @param invite - The invite received out-of-band
|
|
1424
|
-
* @returns Connection to the owner
|
|
1425
|
-
*/
|
|
1426
|
-
async dialInvite(invite: CadreInvite): Promise<void> {
|
|
1427
|
-
if (!this.libp2pNode) {
|
|
1428
|
-
throw new Error('Service not initialized');
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
// Check expiration
|
|
1432
|
-
if (invite.expiresAt && Date.now() > invite.expiresAt) {
|
|
1433
|
-
throw new Error('Invite has expired');
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
|
-
log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
|
|
1437
|
-
|
|
1438
|
-
const addrs = parseDialAddrs(invite.ownerAddrs);
|
|
1439
|
-
if (addrs.length === 0) {
|
|
1440
|
-
throw new Error('No owner addresses available');
|
|
1441
|
-
}
|
|
1442
|
-
// Each address on its own time limit, so one that never answers cannot hold
|
|
1443
|
-
// the invitee back from the rest (`dialPeerAddrs`).
|
|
1444
|
-
const connection = await dialPeerAddrs(this.libp2pNode, addrs, this.dialBudget, 'Invite owner dial');
|
|
1445
|
-
log('Connected to owner at: %s', connection.remoteAddr.toString());
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
|
|
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, Connection } from '@libp2p/interface';
|
|
5
|
+
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
6
|
+
import { peerIdFromString } from '@libp2p/peer-id';
|
|
7
|
+
import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
|
|
8
|
+
import { dialPeerAddrs, SelfRelayOnlyError, DEFAULT_PEER_DIAL_BUDGET, type PeerDialBudget } from './peer-dial.js';
|
|
9
|
+
import { relayedRequestBudgetMs } from './link-budget.js';
|
|
10
|
+
import type {
|
|
11
|
+
ControlNetworkSeed,
|
|
12
|
+
SeedPeer,
|
|
13
|
+
SeedMessage,
|
|
14
|
+
SeedAckMessage,
|
|
15
|
+
AuthorizePeerOptions,
|
|
16
|
+
ApplySeedResult,
|
|
17
|
+
AddDroneOptions,
|
|
18
|
+
AddPhoneOptions,
|
|
19
|
+
DroneInitResult,
|
|
20
|
+
InviteResult,
|
|
21
|
+
CadreInvite,
|
|
22
|
+
PeerAddressRecord,
|
|
23
|
+
DeviceTokenRecord,
|
|
24
|
+
RevocationRow,
|
|
25
|
+
RevocationLedgerOpenResult
|
|
26
|
+
} from './types.js';
|
|
27
|
+
import type { ControlDatabase } from './control-database.js';
|
|
28
|
+
import { generateStampId } from './control-database.js';
|
|
29
|
+
import { canonicalJson } from './canonical-json.js';
|
|
30
|
+
import { deviceTokenAddDigest } from './peer-authorization.js';
|
|
31
|
+
import {
|
|
32
|
+
type SeedTrustPolicy,
|
|
33
|
+
type SeedTrustDecision,
|
|
34
|
+
anchoredTrustPolicy,
|
|
35
|
+
} from './seed-trust-policy.js';
|
|
36
|
+
import type { TrustedOwnerStore } from './trusted-owner-store.js';
|
|
37
|
+
|
|
38
|
+
const log = debug('sereus:cadre:seed-bootstrap');
|
|
39
|
+
|
|
40
|
+
/** Protocol ID for seed delivery */
|
|
41
|
+
export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
|
|
42
|
+
|
|
43
|
+
/** Maximum seed message size (1MB) */
|
|
44
|
+
const MAX_SEED_SIZE = 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default time the receiver waits for an inbound seed frame before aborting (ms). It covers the
|
|
48
|
+
* read only: the trust decision and the peer-store merge run after it.
|
|
49
|
+
*/
|
|
50
|
+
// eslint-disable-next-line no-restricted-syntax -- link-independent: a receiver cap on one seed frame on a stream the peer already opened; it bounds a peer that opens a stream and never sends, not the dial
|
|
51
|
+
const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
|
|
52
|
+
|
|
53
|
+
/** Default cap on concurrent inbound seed streams a single peer can pin open. */
|
|
54
|
+
const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
|
|
58
|
+
*
|
|
59
|
+
* Guards every parse site against malformed input: a buffer too short to hold
|
|
60
|
+
* the prefix, a declared length exceeding `maxLength`, and a declared length
|
|
61
|
+
* exceeding the bytes actually present. Returns a view (`subarray`, no copy) —
|
|
62
|
+
* the body is handed straight to `TextDecoder`.
|
|
63
|
+
*/
|
|
64
|
+
export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED_SIZE): Uint8Array {
|
|
65
|
+
if (data.length < 4) {
|
|
66
|
+
throw new Error(`Seed frame too short: ${data.length} bytes, need ≥4 for length prefix`);
|
|
67
|
+
}
|
|
68
|
+
// Pass the full (buffer, byteOffset, byteLength) triple so the read is correct
|
|
69
|
+
// even for a non-zero-offset view, not just the fresh zero-offset arrays
|
|
70
|
+
// current callers pass.
|
|
71
|
+
const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0, false);
|
|
72
|
+
const available = data.length - 4;
|
|
73
|
+
if (length > maxLength) {
|
|
74
|
+
throw new Error(`Seed frame declares length ${length} exceeding max ${maxLength}`);
|
|
75
|
+
}
|
|
76
|
+
if (length > available) {
|
|
77
|
+
throw new Error(`Seed frame declares length ${length} but only ${available} body bytes present`);
|
|
78
|
+
}
|
|
79
|
+
return data.subarray(4, 4 + length);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
|
|
84
|
+
*
|
|
85
|
+
* An Ed25519 PeerId is an identity multihash of the public key, so
|
|
86
|
+
* `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
|
|
87
|
+
* base64url form matches the `OwnerKey.Key` representation (and
|
|
88
|
+
* `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
|
|
89
|
+
* id, a missing embedded key, or any parse failure — callers treat null as
|
|
90
|
+
* "not an owner" rather than throwing.
|
|
91
|
+
*/
|
|
92
|
+
export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
|
|
93
|
+
try {
|
|
94
|
+
const parsed = peerIdFromString(peerId);
|
|
95
|
+
if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Canonical byte representation of the authenticated seed fields.
|
|
106
|
+
*
|
|
107
|
+
* Routes both the creator (`createSeed`) and the verifier
|
|
108
|
+
* (`validateSeedSignature`) through one builder so the signed bytes are
|
|
109
|
+
* identical regardless of key insertion order. `canonicalJson` sorts keys and
|
|
110
|
+
* drops `undefined`, so the signed payload is exactly `{ partyId, peers }` —
|
|
111
|
+
* the fields the producer actually emits.
|
|
112
|
+
*/
|
|
113
|
+
export function canonicalSeedPayload(
|
|
114
|
+
seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>
|
|
115
|
+
): string {
|
|
116
|
+
return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
|
|
121
|
+
* so the dial counters read zero rather than being absent.
|
|
122
|
+
*/
|
|
123
|
+
function seedRejected(error: string): ApplySeedResult {
|
|
124
|
+
return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** What dialing a seed's owner peers produced — the owner-dial half of an {@link ApplySeedResult}. */
|
|
128
|
+
type OwnerDialCounts = Pick<ApplySeedResult, 'ownerDialsAttempted' | 'ownerDialsFailed'>;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse each address, dropping (and logging) any that is malformed, so one bad
|
|
132
|
+
* entry cannot keep a peer's valid addresses from being dialed.
|
|
133
|
+
*/
|
|
134
|
+
function parseDialAddrs(addrs: readonly string[]): Multiaddr[] {
|
|
135
|
+
const parsed: Multiaddr[] = [];
|
|
136
|
+
for (const addr of addrs) {
|
|
137
|
+
try {
|
|
138
|
+
parsed.push(multiaddr(addr));
|
|
139
|
+
} catch (error) {
|
|
140
|
+
log('Skipping malformed dial address %s: %o', addr, error);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return parsed;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Configuration for the SeedBootstrapService
|
|
148
|
+
*/
|
|
149
|
+
export interface SeedBootstrapConfig {
|
|
150
|
+
/** Party ID for this cadre */
|
|
151
|
+
partyId: string;
|
|
152
|
+
/** Owner private key for signing seeds and peer authorizations (base64url) */
|
|
153
|
+
ownerPrivateKey?: string;
|
|
154
|
+
/** Owner public key (base64url) - derived from private key if not provided */
|
|
155
|
+
ownerPublicKey?: string;
|
|
156
|
+
/**
|
|
157
|
+
* Optional async resolver returning the multiaddrs to embed in invites.
|
|
158
|
+
* When unset, `libp2pNode.getMultiaddrs()` is used. Hosts behind NAT supply
|
|
159
|
+
* this (via `@serfab/cadre-host`'s NatService) to substitute the host's
|
|
160
|
+
* DDNS hostname and externally-mapped port.
|
|
161
|
+
*/
|
|
162
|
+
inviteAddressResolver?: () => Promise<string[]>;
|
|
163
|
+
/**
|
|
164
|
+
* Trust anchor for incoming seeds. Decides whether a signature-verified
|
|
165
|
+
* `signerKey` should be trusted, against the receiver's anchored owner
|
|
166
|
+
* keys (NOT the seed body). Defaults to `anchoredTrustPolicy()`, which
|
|
167
|
+
* rejects any signer not already in {@link trustedOwners}. An enrollment
|
|
168
|
+
* caller can pass a per-seed override to `applySeed` instead.
|
|
169
|
+
*
|
|
170
|
+
* A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
|
|
171
|
+
* — that is the only seam the inbound libp2p seed-protocol handler can use,
|
|
172
|
+
* since a network-delivered seed has no per-call override.
|
|
173
|
+
*/
|
|
174
|
+
trustPolicy?: SeedTrustPolicy;
|
|
175
|
+
/**
|
|
176
|
+
* The node-local, NON-replicated trusted-owner anchor. Supplies
|
|
177
|
+
* `SeedTrustContext.knownOwnerKeys` for every {@link applySeed}, and receives
|
|
178
|
+
* a key accepted via a pin/TOFU (see `SeedTrustDecision.anchorAs`).
|
|
179
|
+
*
|
|
180
|
+
* Deliberately NOT `ControlDatabase.getOwnerKeys()`: the replicated
|
|
181
|
+
* `OwnerKey` table is pollutable — any connecting node can genesis-insert its
|
|
182
|
+
* own key and let it replicate — so a seed signed by a stranger's self-issued
|
|
183
|
+
* owner key would pass a table-anchored check. A `CadreNode` passes its
|
|
184
|
+
* `getTrustedOwnerStore()` here. Unset (e.g. a directly-constructed service in
|
|
185
|
+
* a test) means an EMPTY anchor: only a pinned/TOFU policy can accept a seed.
|
|
186
|
+
*/
|
|
187
|
+
trustedOwners?: TrustedOwnerStore;
|
|
188
|
+
/**
|
|
189
|
+
* Time the inbound seed handler waits for the seed frame before aborting the
|
|
190
|
+
* read (ms). Defaults to {@link DEFAULT_SEED_READ_TIMEOUT_MS}. Bounds a
|
|
191
|
+
* buggy/compromised own-cadre node that opens a stream and never half-closes.
|
|
192
|
+
*/
|
|
193
|
+
seedReadTimeoutMs?: number;
|
|
194
|
+
/**
|
|
195
|
+
* Cap on concurrent inbound seed streams (defaults to
|
|
196
|
+
* {@link DEFAULT_MAX_CONCURRENT_SEEDS}). Over the cap, a non-accepting ack is
|
|
197
|
+
* returned without applying any seed.
|
|
198
|
+
*/
|
|
199
|
+
maxConcurrentSeeds?: number;
|
|
200
|
+
/**
|
|
201
|
+
* The host's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
|
|
202
|
+
* {@link seedDeliverTimeoutMs}'s default is derived. Unset means the declared default.
|
|
203
|
+
*/
|
|
204
|
+
linkRoundTripMs?: number;
|
|
205
|
+
/**
|
|
206
|
+
* Time {@link SeedBootstrapService.deliverSeed} waits for the whole exchange —
|
|
207
|
+
* dial, write, ack read — before aborting (ms). Bounds the SENDER against a seed
|
|
208
|
+
* target that accepts the stream and then never replies; the target is a
|
|
209
|
+
* not-yet-trusted node during onboarding, so this is the more exposed
|
|
210
|
+
* direction than the receiver knobs above.
|
|
211
|
+
*
|
|
212
|
+
* Defaults to `relayedRequestBudgetMs(linkRoundTripMs)` (`link-budget.ts`; 28.5 s at the
|
|
213
|
+
* default declaration): one dial that may need a relay, then one request and its answer.
|
|
214
|
+
* Delivery does not set `runOnLimitedConnection`, so it does not use a limited relayed
|
|
215
|
+
* connection today; the relayed-dial count is the upper bound on the dial it can use, the
|
|
216
|
+
* same choice `CadreNode.controlDialBudget` makes for every address. It holds only link work
|
|
217
|
+
* because the receiver acks before its owner dials.
|
|
218
|
+
*
|
|
219
|
+
* NOTE: no transfer allowance — a seed is a peer list of a few KB, and `MAX_SEED_SIZE` (1 MiB)
|
|
220
|
+
* is a defensive cap. If seeds ever grow toward that cap, add an allowance the way
|
|
221
|
+
* `PUSH_TRANSFER_ALLOWANCE_MS` does.
|
|
222
|
+
*/
|
|
223
|
+
seedDeliverTimeoutMs?: number;
|
|
224
|
+
/**
|
|
225
|
+
* Time limits for each peer this service dials from a list of addresses —
|
|
226
|
+
* {@link SeedBootstrapService.applySeed}'s owner dials and
|
|
227
|
+
* {@link SeedBootstrapService.dialInvite} — per address and per peer (see
|
|
228
|
+
* `peer-dial.ts`). Defaults to {@link DEFAULT_PEER_DIAL_BUDGET}; a `CadreNode`
|
|
229
|
+
* passes its `network.controlCohort` limits.
|
|
230
|
+
*/
|
|
231
|
+
dialBudget?: PeerDialBudget;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Event callbacks for seed-related events
|
|
236
|
+
*/
|
|
237
|
+
export interface SeedEventCallbacks {
|
|
238
|
+
/** Called when a seed is received via the protocol */
|
|
239
|
+
onSeedReceived?: (partyId: string, peerId: string) => void;
|
|
240
|
+
/**
|
|
241
|
+
* Called when a seed is successfully applied.
|
|
242
|
+
*
|
|
243
|
+
* `seed` is the applied seed itself: the inbound protocol handler applies it
|
|
244
|
+
* INSIDE the service, so this callback is the only seam through which a
|
|
245
|
+
* `CadreNode` sees a wire-delivered seed's contents — which it needs to
|
|
246
|
+
* retain the owner-flagged peers as cold-start bootstrap dial targets.
|
|
247
|
+
*/
|
|
248
|
+
onSeedApplied?: (partyId: string, peersAdded: number, seed: ControlNetworkSeed) => void;
|
|
249
|
+
/** Called when seed application fails */
|
|
250
|
+
onSeedError?: (partyId: string, error: string) => void;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* SeedBootstrapService handles control network seed generation and delivery.
|
|
255
|
+
*
|
|
256
|
+
* Seeds solve the cold-start problem: new nodes need control data to validate
|
|
257
|
+
* connections, but can't get data without connecting first. Seeds pre-populate
|
|
258
|
+
* the new node's cache with peer information.
|
|
259
|
+
*/
|
|
260
|
+
export class SeedBootstrapService {
|
|
261
|
+
private readonly config: SeedBootstrapConfig;
|
|
262
|
+
private libp2pNode: Libp2p | null = null;
|
|
263
|
+
private controlDatabase: ControlDatabase | null = null;
|
|
264
|
+
private readonly ownerPublicKey: string | null;
|
|
265
|
+
private readonly trustPolicy: SeedTrustPolicy;
|
|
266
|
+
private readonly seedReadTimeoutMs: number;
|
|
267
|
+
private readonly maxConcurrentSeeds: number;
|
|
268
|
+
private readonly seedDeliverTimeoutMs: number;
|
|
269
|
+
private readonly dialBudget: PeerDialBudget;
|
|
270
|
+
/** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
|
|
271
|
+
private activeStreams = 0;
|
|
272
|
+
private eventCallbacks: SeedEventCallbacks = {};
|
|
273
|
+
|
|
274
|
+
constructor(config: SeedBootstrapConfig) {
|
|
275
|
+
this.config = config;
|
|
276
|
+
this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
|
|
277
|
+
this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
|
|
278
|
+
this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
|
|
279
|
+
this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
|
|
280
|
+
this.dialBudget = config.dialBudget ?? DEFAULT_PEER_DIAL_BUDGET;
|
|
281
|
+
|
|
282
|
+
// Derive public key from private key if not provided
|
|
283
|
+
if (config.ownerPrivateKey && !config.ownerPublicKey) {
|
|
284
|
+
this.ownerPublicKey = getPublicKey(
|
|
285
|
+
config.ownerPrivateKey,
|
|
286
|
+
'ed25519',
|
|
287
|
+
'base64url',
|
|
288
|
+
'base64url'
|
|
289
|
+
) as string;
|
|
290
|
+
} else {
|
|
291
|
+
this.ownerPublicKey = config.ownerPublicKey ?? null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
log('SeedBootstrapService created for party: %s', config.partyId);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Set event callbacks for seed-related events.
|
|
299
|
+
* Used by CadreNode to emit events.
|
|
300
|
+
*/
|
|
301
|
+
setEventCallbacks(callbacks: SeedEventCallbacks): void {
|
|
302
|
+
this.eventCallbacks = callbacks;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Whether this service holds an owner private key, i.e. can produce the
|
|
307
|
+
* owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
|
|
308
|
+
* and re-authorizations. A seed-listener-only service (`enableSeedListener`,
|
|
309
|
+
* no owner key) returns false: it can receive/apply seeds but cannot author
|
|
310
|
+
* or re-issue owner writes. Used by the write-while-alone re-replication
|
|
311
|
+
* drain to skip owner work on a non-owner node.
|
|
312
|
+
*/
|
|
313
|
+
canAuthorize(): boolean {
|
|
314
|
+
return !!this.config.ownerPrivateKey;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Initialize the service with libp2p node and control database.
|
|
319
|
+
*
|
|
320
|
+
* `registerHandler` (default true) gates registration of the shared inbound
|
|
321
|
+
* `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
|
|
322
|
+
* (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
|
|
323
|
+
* it on. The throwaway temp services CadreNode builds in `applySeed` /
|
|
324
|
+
* `dialInvite` pass `false`: they only need the stored `libp2pNode` /
|
|
325
|
+
* `controlDatabase` for dialing and known-key lookup, and must NOT bind a
|
|
326
|
+
* discarded closure to the shared node (a handler leak, and a second
|
|
327
|
+
* `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
|
|
328
|
+
*
|
|
329
|
+
* Rejects when libp2p refuses the registration; the node and database are
|
|
330
|
+
* kept only once the handler is in place, so a failed service holds nothing
|
|
331
|
+
* for {@link shutdown} to unhandle.
|
|
332
|
+
*/
|
|
333
|
+
async initialize(
|
|
334
|
+
libp2pNode: Libp2p,
|
|
335
|
+
controlDatabase: ControlDatabase,
|
|
336
|
+
options?: { registerHandler?: boolean }
|
|
337
|
+
): Promise<void> {
|
|
338
|
+
// Register the seed protocol handler unless the caller opted out (temp services).
|
|
339
|
+
if (options?.registerHandler ?? true) {
|
|
340
|
+
await this.registerProtocolHandler(libp2pNode);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
this.libp2pNode = libp2pNode;
|
|
344
|
+
this.controlDatabase = controlDatabase;
|
|
345
|
+
log('SeedBootstrapService initialized');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Authorize a new peer to join the cadre.
|
|
350
|
+
* Signs a membership voucher with the owner key and inserts into CadrePeer table.
|
|
351
|
+
*
|
|
352
|
+
* The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
|
|
353
|
+
* caller-supplied key, the binding is enforced by construction — `PublicKey` is
|
|
354
|
+
* DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
|
|
355
|
+
* `PublicKey`, and such a row can never be self-updated (it has no key to
|
|
356
|
+
* verify against), which is correct. The row is inserted with a fresh
|
|
357
|
+
* `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
|
|
358
|
+
* the peer's self-signature, so the peer must self-publish (see
|
|
359
|
+
* {@link CadreNode.registerSelf}) before the row resolves.
|
|
360
|
+
*/
|
|
361
|
+
async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
|
|
362
|
+
const { peerId, multiaddrs } = options;
|
|
363
|
+
log('Authorizing peer: %s', peerId);
|
|
364
|
+
// CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
|
|
365
|
+
const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
|
|
366
|
+
await this.insertCadrePeerRow({
|
|
367
|
+
peerId,
|
|
368
|
+
publicKey: ed25519PublicKeyB64FromPeerId(peerId),
|
|
369
|
+
multiaddr: multiaddrStr,
|
|
370
|
+
updatedAt: Date.now(),
|
|
371
|
+
sig: null,
|
|
372
|
+
});
|
|
373
|
+
log('Peer %s authorized successfully', peerId);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Owner-signed INSERT of this node's OWN self-signed address record.
|
|
378
|
+
*
|
|
379
|
+
* Used by {@link CadreNode.registerSelf} when the node is not yet a member and
|
|
380
|
+
* is its own owner (it holds the owner key): the row is owner-signed
|
|
381
|
+
* (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
|
|
382
|
+
* immediately without a follow-up self-update.
|
|
383
|
+
*
|
|
384
|
+
* @returns `true` when this call seated the row, `false` when a concurrent writer
|
|
385
|
+
* (e.g. an {@link authorizePeer} of this node's own id) had already seated it —
|
|
386
|
+
* in which case the row in the database is NOT this record and carries whatever
|
|
387
|
+
* `Sig` that writer had, so the caller must self-update to publish its signature.
|
|
388
|
+
*/
|
|
389
|
+
async insertSelfPeerRecord(record: PeerAddressRecord): Promise<boolean> {
|
|
390
|
+
return await this.insertCadrePeerRow({
|
|
391
|
+
peerId: record.peerId,
|
|
392
|
+
publicKey: record.publicKey,
|
|
393
|
+
multiaddr: record.addrs.join(','),
|
|
394
|
+
updatedAt: record.updatedAt,
|
|
395
|
+
sig: record.sig,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
|
|
401
|
+
* precondition; the stamp mint, voucher signature, write lock, existence check and
|
|
402
|
+
* membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
|
|
403
|
+
* the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
|
|
404
|
+
* method: the write is what admits the peer's traffic.
|
|
405
|
+
*
|
|
406
|
+
* The DB check precedes the owner-key check here, matching the order this method has
|
|
407
|
+
* always surfaced them (the owner-key error used to come out of the signer, after the
|
|
408
|
+
* DB check).
|
|
409
|
+
*
|
|
410
|
+
* @returns `true` when this call performed the INSERT, `false` when the row was
|
|
411
|
+
* already seated by a concurrent writer. The loser needs to know: an authorize seats
|
|
412
|
+
* a row with a null `Sig`, so a self-publish that lost the race must fall through to
|
|
413
|
+
* a self-update or its record never lands.
|
|
414
|
+
*/
|
|
415
|
+
private async insertCadrePeerRow(row: {
|
|
416
|
+
peerId: string;
|
|
417
|
+
publicKey: string | null;
|
|
418
|
+
multiaddr: string;
|
|
419
|
+
updatedAt: number;
|
|
420
|
+
sig: string | null;
|
|
421
|
+
}): Promise<boolean> {
|
|
422
|
+
if (!this.controlDatabase) {
|
|
423
|
+
throw new Error('Control database not initialized');
|
|
424
|
+
}
|
|
425
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
426
|
+
return await this.controlDatabase.insertCadrePeer(
|
|
427
|
+
row, ownerKey, message => this.signMessageBytes(message),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
|
|
433
|
+
*
|
|
434
|
+
* Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
|
|
435
|
+
* row is owner-signed (satisfying `DeviceToken.AuthorizedInsert` via the
|
|
436
|
+
* 'add'-tagged digest, {@link deviceTokenAddDigest}) AND carries the peer's
|
|
437
|
+
* own self-`Sig` over the token payload. The owner signature covers the WHOLE row
|
|
438
|
+
* (every column, ending in a freshly minted single-use `StampId`) — but covering the
|
|
439
|
+
* token contents is not the same as vouching them: the peer's `Sig` (verified at
|
|
440
|
+
* resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
|
|
441
|
+
* resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
|
|
442
|
+
* when the node is its own owner.
|
|
443
|
+
*
|
|
444
|
+
* The stamp is per-INSERT, so a re-register after a clear mints a fresh one and a
|
|
445
|
+
* fresh signature — unaffected by the cleared row's retired stamp
|
|
446
|
+
* (`DeviceToken.NotRevoked`).
|
|
447
|
+
*/
|
|
448
|
+
async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
|
|
449
|
+
const stampId = generateStampId(record.peerId);
|
|
450
|
+
const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
|
|
451
|
+
if (!this.controlDatabase) {
|
|
452
|
+
throw new Error('Control database not initialized');
|
|
453
|
+
}
|
|
454
|
+
await this.controlDatabase.execWrite(`
|
|
455
|
+
insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
|
|
456
|
+
with context OwnerKey = ?, Signature = ?
|
|
457
|
+
values (?, ?, ?, ?, ?, ?)
|
|
458
|
+
`, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
|
|
459
|
+
log('Device token inserted (owner-signed): %s', record.peerId);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
|
|
464
|
+
* invalidation). The `DeviceToken.AuthorizedDelete` constraint validates an owner
|
|
465
|
+
* signature over the 'remove'-tagged digest bound to the STORED row's
|
|
466
|
+
* (PeerId, StampId) — deliberately distinct from the insert digest, so a captured
|
|
467
|
+
* insert approval can never be replayed to clear a token. Like {@link removePeer}
|
|
468
|
+
* for `CadrePeer`, clearing a token requires the owner key.
|
|
469
|
+
*
|
|
470
|
+
* The delete and the `Revocation` tombstone retiring the row's `StampId` commit in
|
|
471
|
+
* ONE transaction — `DeviceToken.RevocationRecorded` refuses a bare delete, and
|
|
472
|
+
* without the tombstone the stamp would free up and the never-expiring insert
|
|
473
|
+
* approval (which the cleared device holds a copy of) would re-seat the token. The
|
|
474
|
+
* tombstone carries its OWN owner signature: retiring a stamp permanently forecloses
|
|
475
|
+
* that row, so it is an owner action in its own right.
|
|
476
|
+
*
|
|
477
|
+
* Both digests, the stamp read, and the transaction come from
|
|
478
|
+
* {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
|
|
479
|
+
* `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
|
|
480
|
+
* owner-key precondition; a no-op on an already-absent row is the shared body's
|
|
481
|
+
* behavior, and unlike {@link removePeer} nothing here rides on it.
|
|
482
|
+
*/
|
|
483
|
+
async deleteDeviceToken(peerId: string): Promise<void> {
|
|
484
|
+
// Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
|
|
485
|
+
// owner-key error rather than a silent no-op on an absent row.
|
|
486
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
487
|
+
if (!this.controlDatabase) {
|
|
488
|
+
throw new Error('Control database not initialized');
|
|
489
|
+
}
|
|
490
|
+
await this.controlDatabase.deleteDeviceToken(
|
|
491
|
+
peerId, ownerKey, message => this.signMessageBytes(message),
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Return the configured owner private key, or throw if none is set. The single
|
|
497
|
+
* precondition gate for every owner-signed write. {@link removePeer} /
|
|
498
|
+
* {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
|
|
499
|
+
* they call this up front — otherwise a keyless service would either surface the
|
|
500
|
+
* wrong "Control database not initialized" error or, worse, silently no-op when the
|
|
501
|
+
* target row is absent (the early `stampId === null` return) instead of rejecting.
|
|
502
|
+
*/
|
|
503
|
+
private requireOwnerPrivateKey(): string {
|
|
504
|
+
if (!this.config.ownerPrivateKey) {
|
|
505
|
+
throw new Error('Owner private key required to authorize peers');
|
|
506
|
+
}
|
|
507
|
+
return this.config.ownerPrivateKey;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The owner PUBLIC key that rides in every owner-signed write's context, non-null.
|
|
512
|
+
*
|
|
513
|
+
* The field is nullable because a read-only service carries no owner key at all, but the
|
|
514
|
+
* constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
|
|
515
|
+
* {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
|
|
516
|
+
* unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
|
|
517
|
+
* their first line and get the owner-key precondition for free.
|
|
518
|
+
*/
|
|
519
|
+
private requireOwnerPublicKey(): string {
|
|
520
|
+
this.requireOwnerPrivateKey();
|
|
521
|
+
if (!this.ownerPublicKey) {
|
|
522
|
+
throw new Error('Owner public key required to authorize peers');
|
|
523
|
+
}
|
|
524
|
+
return this.ownerPublicKey;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Sign a base64url digest with the owner key (ed25519). The single place the
|
|
529
|
+
* owner private key is applied; callers pass the canonical domain-tagged digest for
|
|
530
|
+
* the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
|
|
531
|
+
* {@link signMessageBytes}. Throws if no owner key is set.
|
|
532
|
+
*/
|
|
533
|
+
private signDigest(digestB64url: string): string {
|
|
534
|
+
return sign(
|
|
535
|
+
digestB64url,
|
|
536
|
+
this.requireOwnerPrivateKey(),
|
|
537
|
+
'ed25519',
|
|
538
|
+
'base64url',
|
|
539
|
+
'base64url',
|
|
540
|
+
'base64url'
|
|
541
|
+
) as string;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
|
|
546
|
+
* takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
|
|
547
|
+
* same signed bytes (`sign` decodes its base64url input), so a signature minted here
|
|
548
|
+
* satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
|
|
549
|
+
* `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
|
|
550
|
+
*/
|
|
551
|
+
private signMessageBytes(message: Uint8Array): string {
|
|
552
|
+
return this.signDigest(uint8ArrayToString(message, 'base64url'));
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Remove a peer from the cadre by owner signature.
|
|
557
|
+
*
|
|
558
|
+
* The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
|
|
559
|
+
* signature over the DISTINCT 'remove'-tagged digest
|
|
560
|
+
* `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
|
|
561
|
+
* key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
|
|
562
|
+
* never be replayed to delete.
|
|
563
|
+
*
|
|
564
|
+
* The delete and the `Revocation` tombstone retiring the row's `StampId` commit in ONE
|
|
565
|
+
* transaction — `CadrePeer.RevocationRecorded` refuses a bare delete, and without the
|
|
566
|
+
* tombstone the stamp would free up and the original admission approval (which never
|
|
567
|
+
* expires, and which the removed peer holds a copy of) would re-seat the row.
|
|
568
|
+
*
|
|
569
|
+
* The tombstone is separately owner-signed (satisfying `Revocation.Authorized`):
|
|
570
|
+
* retiring a stamp evicts that peer party-wide and permanently forecloses re-admitting
|
|
571
|
+
* the row, so it is an owner action in its own right, not a side effect the delete's
|
|
572
|
+
* signature covers.
|
|
573
|
+
*
|
|
574
|
+
* Both digests, the stamp read, the transaction, and the post-commit membership notify
|
|
575
|
+
* come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
|
|
576
|
+
* `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
|
|
577
|
+
* owner-key precondition and the absent-row gate the notify depends on (below).
|
|
578
|
+
*/
|
|
579
|
+
async removePeer(peerId: string): Promise<void> {
|
|
580
|
+
// Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
|
|
581
|
+
// remove digest, and this precedence (owner key, then control DB) is what the
|
|
582
|
+
// unit contract asserts.
|
|
583
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
584
|
+
if (!this.controlDatabase) {
|
|
585
|
+
throw new Error('Control database not initialized');
|
|
586
|
+
}
|
|
587
|
+
// This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
|
|
588
|
+
// internally: deleteCadrePeer's membership notify fires whenever its body resolves,
|
|
589
|
+
// with no idea whether the body wrote anything, so delegating an absent peer would
|
|
590
|
+
// fire a spurious membership notification.
|
|
591
|
+
// NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
|
|
592
|
+
// between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
|
|
593
|
+
// window only — the common "already absent" case is caught here.
|
|
594
|
+
const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
|
|
595
|
+
if (stampId === null) {
|
|
596
|
+
log('removePeer: no CadrePeer row for %s (already absent)', peerId);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
log('Removing peer: %s', peerId);
|
|
601
|
+
|
|
602
|
+
await this.controlDatabase.deleteCadrePeer(
|
|
603
|
+
peerId, ownerKey, message => this.signMessageBytes(message),
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
log('Peer %s removed successfully (stamp retired)', peerId);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
|
|
611
|
+
* re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
|
|
612
|
+
* the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
|
|
613
|
+
* a membership row that committed local-only (its block's cluster ≤1 at insert) is
|
|
614
|
+
* pushed to the cohort once it grows.
|
|
615
|
+
*
|
|
616
|
+
* The stamp read, the voucher signature, the write lock and the membership notify are
|
|
617
|
+
* {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
|
|
618
|
+
* `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
|
|
619
|
+
*
|
|
620
|
+
* A no-op (no throw, no notify) when the row does not exist.
|
|
621
|
+
*
|
|
622
|
+
* @param peerId - the membership row to re-touch.
|
|
623
|
+
* @param updatedAt - the strictly-increasing freshness stamp to write.
|
|
624
|
+
* @throws if no owner private key is configured (a non-owner cannot
|
|
625
|
+
* re-sign another peer's row) or the control database is not initialized.
|
|
626
|
+
*/
|
|
627
|
+
async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
|
|
628
|
+
// Fail fast on a keyless service before any DB read (see removePeer): a non-owner
|
|
629
|
+
// cannot re-sign the voucher, and must not silently no-op on an absent row.
|
|
630
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
631
|
+
if (!this.controlDatabase) {
|
|
632
|
+
throw new Error('Control database not initialized');
|
|
633
|
+
}
|
|
634
|
+
const retouched = await this.controlDatabase.reauthorizeCadrePeer(
|
|
635
|
+
peerId, updatedAt, ownerKey, message => this.signMessageBytes(message),
|
|
636
|
+
);
|
|
637
|
+
if (!retouched) {
|
|
638
|
+
log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Owner re-issue of a batch of `Revocation` tombstones: bump each row's
|
|
646
|
+
* `ReissuedAt` so the storage layer re-broadcasts a tombstone that committed
|
|
647
|
+
* while the node was alone. The delete-while-alone counterpart of
|
|
648
|
+
* {@link reauthorizePeer} — a removed row cannot be re-touched (it is gone),
|
|
649
|
+
* but its tombstone can, and every membership read treats a retired stamp as
|
|
650
|
+
* absent.
|
|
651
|
+
*
|
|
652
|
+
* The signatures, the single transaction, and the strictly-monotonic
|
|
653
|
+
* `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
|
|
654
|
+
* stays here is the owner-key precondition.
|
|
655
|
+
*
|
|
656
|
+
* @returns how many tombstones were re-issued (`rows.length` on success).
|
|
657
|
+
* @throws if no owner private key is configured or the control database is not
|
|
658
|
+
* initialized.
|
|
659
|
+
*/
|
|
660
|
+
async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number): Promise<number> {
|
|
661
|
+
// Fail fast on a keyless service before any DB work (see removePeer): a
|
|
662
|
+
// non-owner cannot sign the reissue digests.
|
|
663
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
664
|
+
if (!this.controlDatabase) {
|
|
665
|
+
throw new Error('Control database not initialized');
|
|
666
|
+
}
|
|
667
|
+
return this.controlDatabase.reissueRevocations(
|
|
668
|
+
rows, reissuedAt, ownerKey, message => this.signMessageBytes(message),
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Owner filing of the singleton `Revocation` ledger marker, so the table is never a
|
|
674
|
+
* never-written block that the storage layer re-consults on every read.
|
|
675
|
+
*
|
|
676
|
+
* The signature, the insert-if-absent guard and the `'already-open'` mapping are
|
|
677
|
+
* {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-key
|
|
678
|
+
* precondition.
|
|
679
|
+
*
|
|
680
|
+
* @throws if no owner private key is configured or the control database is not
|
|
681
|
+
* initialized.
|
|
682
|
+
*/
|
|
683
|
+
async openRevocationLedger(): Promise<RevocationLedgerOpenResult> {
|
|
684
|
+
// Fail fast on a keyless service before any DB work (see removePeer): a
|
|
685
|
+
// non-owner cannot sign the marker's append digest.
|
|
686
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
687
|
+
if (!this.controlDatabase) {
|
|
688
|
+
throw new Error('Control database not initialized');
|
|
689
|
+
}
|
|
690
|
+
return this.controlDatabase.openRevocationLedger(
|
|
691
|
+
ownerKey, message => this.signMessageBytes(message),
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Create a seed from the current control network state.
|
|
697
|
+
* The seed contains peer information and is signed by an owner.
|
|
698
|
+
*/
|
|
699
|
+
async createSeed(): Promise<ControlNetworkSeed> {
|
|
700
|
+
if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
|
|
701
|
+
throw new Error('Owner key required to create seeds');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
if (!this.controlDatabase || !this.libp2pNode) {
|
|
705
|
+
throw new Error('Service not initialized');
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
log('Creating seed for party: %s', this.config.partyId);
|
|
709
|
+
|
|
710
|
+
// Query all peers from the control database
|
|
711
|
+
const peers = await this.queryPeers();
|
|
712
|
+
|
|
713
|
+
// Create the seed data (without signature)
|
|
714
|
+
const seedData = {
|
|
715
|
+
partyId: this.config.partyId,
|
|
716
|
+
peers,
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
// Sign the seed over its canonical byte representation
|
|
720
|
+
const seedJson = canonicalSeedPayload(seedData);
|
|
721
|
+
const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
|
|
722
|
+
const signature = sign(
|
|
723
|
+
seedDigest,
|
|
724
|
+
this.config.ownerPrivateKey,
|
|
725
|
+
'ed25519',
|
|
726
|
+
'base64url',
|
|
727
|
+
'base64url',
|
|
728
|
+
'base64url'
|
|
729
|
+
) as string;
|
|
730
|
+
|
|
731
|
+
const seed: ControlNetworkSeed = {
|
|
732
|
+
...seedData,
|
|
733
|
+
signature,
|
|
734
|
+
signerKey: this.ownerPublicKey,
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
log('Created seed with %d peers', peers.length);
|
|
738
|
+
return seed;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Apply a seed to populate the peer cache and enable connections.
|
|
743
|
+
*
|
|
744
|
+
* Validates the seed signature, then evaluates a trust anchor for the
|
|
745
|
+
* `signerKey` that does NOT come from the seed body: the receiver's
|
|
746
|
+
* node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
|
|
747
|
+
* augmented by pinned keys or TOFU via the configured/overriding
|
|
748
|
+
* `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
|
|
749
|
+
* own signer as an owner peer — no longer passes, and neither does one signed
|
|
750
|
+
* by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
|
|
751
|
+
*
|
|
752
|
+
* A key accepted via a pin/TOFU is persisted into the anchor (the policy says
|
|
753
|
+
* so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
|
|
754
|
+
* anchored without re-supplying the invite.
|
|
755
|
+
*
|
|
756
|
+
* @param seed - The seed to apply (already transport-decoded).
|
|
757
|
+
* @param options.trustPolicy - Per-call policy override (e.g. a
|
|
758
|
+
* `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
|
|
759
|
+
* service-configured default for this seed only.
|
|
760
|
+
*/
|
|
761
|
+
async applySeed(
|
|
762
|
+
seed: ControlNetworkSeed,
|
|
763
|
+
options?: { trustPolicy?: SeedTrustPolicy }
|
|
764
|
+
): Promise<ApplySeedResult> {
|
|
765
|
+
const merged = await this.verifyAndMergeSeed(seed, options);
|
|
766
|
+
if (!merged.success) {
|
|
767
|
+
return merged;
|
|
768
|
+
}
|
|
769
|
+
return { ...merged, ...await this.dialSeedOwners(seed) };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The first half of {@link applySeed}: check the signature and the signer's trust, then merge
|
|
774
|
+
* the seed's peer addresses into the peer store. Every rejection happens here, so this result is
|
|
775
|
+
* what the inbound handler acks with; the owner dials that follow cannot change it.
|
|
776
|
+
*/
|
|
777
|
+
private async verifyAndMergeSeed(
|
|
778
|
+
seed: ControlNetworkSeed,
|
|
779
|
+
options?: { trustPolicy?: SeedTrustPolicy }
|
|
780
|
+
): Promise<ApplySeedResult> {
|
|
781
|
+
if (!this.libp2pNode) {
|
|
782
|
+
return seedRejected('Service not initialized');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// NOTE: `seed.partyId` is never checked against `config.partyId`. Nothing
|
|
786
|
+
// downstream reads it — trust is keyed on `signerKey` vs the anchor, and the
|
|
787
|
+
// anchor a stray-party seed could write into belongs to THIS party, which
|
|
788
|
+
// only a caller-supplied pin for that signer can reach. If applying a seed
|
|
789
|
+
// ever branches on its partyId (or the anchor becomes multi-party), reject a
|
|
790
|
+
// mismatch here instead.
|
|
791
|
+
log('Applying seed for party: %s', seed.partyId);
|
|
792
|
+
|
|
793
|
+
// Validate the seed signature
|
|
794
|
+
if (!this.validateSeedSignature(seed)) {
|
|
795
|
+
return seedRejected('Invalid seed signature');
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Evaluate the trust anchor for the signer key. The known-owner set comes
|
|
799
|
+
// from the receiver's NODE-LOCAL anchor — never from the seed itself, and
|
|
800
|
+
// never from the replicated OwnerKey table (a stranger can genesis-insert
|
|
801
|
+
// its own key there and let it replicate into every peer's copy). A node
|
|
802
|
+
// whose anchor was never seeded, with no policy override, sees an empty set
|
|
803
|
+
// and rejects.
|
|
804
|
+
const knownOwnerKeys = this.config.trustedOwners?.all() ?? new Set<string>();
|
|
805
|
+
const policy = options?.trustPolicy ?? this.trustPolicy;
|
|
806
|
+
const decision = await policy.evaluate({
|
|
807
|
+
partyId: seed.partyId,
|
|
808
|
+
signerKey: seed.signerKey,
|
|
809
|
+
knownOwnerKeys,
|
|
810
|
+
});
|
|
811
|
+
if (!decision.trusted) {
|
|
812
|
+
return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
|
|
813
|
+
}
|
|
814
|
+
await this.anchorAcceptedSigner(seed.signerKey, decision);
|
|
815
|
+
|
|
816
|
+
let peersAdded = 0;
|
|
817
|
+
|
|
818
|
+
// Add peers to the peer store
|
|
819
|
+
for (const peer of seed.peers) {
|
|
820
|
+
try {
|
|
821
|
+
// Import peer multiaddrs into the peer store
|
|
822
|
+
if (peer.multiaddrs.length > 0) {
|
|
823
|
+
const peerId = peerIdFromString(peer.peerId);
|
|
824
|
+
const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
|
|
825
|
+
|
|
826
|
+
await this.libp2pNode.peerStore.merge(peerId, {
|
|
827
|
+
multiaddrs: addrs
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
peersAdded++;
|
|
831
|
+
log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
|
|
832
|
+
}
|
|
833
|
+
} catch (error) {
|
|
834
|
+
log('Failed to add peer %s: %o', peer.peerId, error);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
log('Merged seed: %d peers added', peersAdded);
|
|
839
|
+
return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The second half of {@link applySeed}: dial the seed's owner peers to establish connections.
|
|
844
|
+
*
|
|
845
|
+
* Best-effort and COUNTED: an owner that is momentarily down leaves this node seeded but
|
|
846
|
+
* unconnected, which the caller can only see if the outcome is reported (see
|
|
847
|
+
* `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
|
|
848
|
+
* `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
|
|
849
|
+
* reconcile pass until the control database has siblings.
|
|
850
|
+
*
|
|
851
|
+
* The inbound handler runs this AFTER it has acked and closed the stream
|
|
852
|
+
* ({@link handleSeedStream}), because an unreachable owner can take `dialBudget.totalMs`.
|
|
853
|
+
*/
|
|
854
|
+
private async dialSeedOwners(seed: ControlNetworkSeed): Promise<OwnerDialCounts> {
|
|
855
|
+
const node = this.libp2pNode;
|
|
856
|
+
if (!node) {
|
|
857
|
+
log('Seed service shut down before its owner dials; none attempted');
|
|
858
|
+
return { ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
859
|
+
}
|
|
860
|
+
// `createSeed` projects every non-revoked CadrePeer row, so an owner applying
|
|
861
|
+
// a seed minted after it joined finds ITSELF in the owner list. Dialing self always
|
|
862
|
+
// throws, which would report a healthy owner as "seeded but stranded".
|
|
863
|
+
// Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
|
|
864
|
+
// and an undefined self simply matches nothing.
|
|
865
|
+
// Every one of an owner's addresses is a candidate, each on its own time limit
|
|
866
|
+
// (`dialPeerAddrs`), so an owner whose first address never answers neither
|
|
867
|
+
// stalls seed application nor goes undialed at its other addresses.
|
|
868
|
+
const selfPeerId = node.peerId?.toString();
|
|
869
|
+
let ownerDialsAttempted = 0;
|
|
870
|
+
let ownerDialsFailed = 0;
|
|
871
|
+
for (const peer of seed.peers.filter(p => p.isOwner)) {
|
|
872
|
+
if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
ownerDialsAttempted++;
|
|
876
|
+
try {
|
|
877
|
+
const addrs = parseDialAddrs(peer.multiaddrs);
|
|
878
|
+
|
|
879
|
+
log('Dialing owner peer: %s (%d addr(s))', peer.peerId, addrs.length);
|
|
880
|
+
await dialPeerAddrs(node, addrs, this.dialBudget, `Owner dial of ${peer.peerId}`);
|
|
881
|
+
} catch (error) {
|
|
882
|
+
// Counted even when the owner reaches us only through our own relay: this node is still
|
|
883
|
+
// not connected to it, and `ownerDialsFailed` is how the caller learns that.
|
|
884
|
+
ownerDialsFailed++;
|
|
885
|
+
if (error instanceof SelfRelayOnlyError) {
|
|
886
|
+
log('Owner peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
|
|
887
|
+
} else {
|
|
888
|
+
log('Failed to dial peer %s: %o', peer.peerId, error);
|
|
889
|
+
}
|
|
890
|
+
// Continue - not all peers need to be reachable
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
log('Dialed seed owners: %d/%d owner dial(s) failed', ownerDialsFailed, ownerDialsAttempted);
|
|
895
|
+
return { ownerDialsAttempted, ownerDialsFailed };
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
|
|
900
|
+
* later seed from the same owner is anchored without re-supplying the invite
|
|
901
|
+
* or re-prompting. Only the policy decides this happens (`anchorAs` is unset
|
|
902
|
+
* when the key was already anchored, so a plain re-apply writes nothing) and
|
|
903
|
+
* `trust()` is idempotent, keeping the original provenance for a known key.
|
|
904
|
+
*
|
|
905
|
+
* Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
|
|
906
|
+
* in-memory anchor synchronously, so this seed and the rest of the session are
|
|
907
|
+
* unaffected — only durability across a restart is lost, and that is logged.
|
|
908
|
+
*
|
|
909
|
+
* NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
|
|
910
|
+
* unauthorized to authorized, which the write-driven membership-gate refresh
|
|
911
|
+
* (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. Every
|
|
912
|
+
* anchor mutation today rides seed application, and both seed paths refresh the
|
|
913
|
+
* gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`). If some
|
|
914
|
+
* future path anchors an owner OUTSIDE seed application, it owes the same
|
|
915
|
+
* `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
|
|
916
|
+
*/
|
|
917
|
+
private async anchorAcceptedSigner(signerKey: string, decision: SeedTrustDecision): Promise<void> {
|
|
918
|
+
if (!decision.anchorAs || !this.config.trustedOwners) {
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
try {
|
|
922
|
+
await this.config.trustedOwners.trust(signerKey, decision.anchorAs);
|
|
923
|
+
log('Anchored seed signer %s as %s', signerKey, decision.anchorAs);
|
|
924
|
+
} catch (error) {
|
|
925
|
+
log('Failed to persist accepted seed signer into the trusted-owner anchor: %o', error);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
|
|
931
|
+
*/
|
|
932
|
+
encodeSeed(seed: ControlNetworkSeed): string {
|
|
933
|
+
const json = JSON.stringify(seed);
|
|
934
|
+
return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Decode a seed from base64url encoding.
|
|
939
|
+
*/
|
|
940
|
+
decodeSeed(encoded: string): ControlNetworkSeed {
|
|
941
|
+
const bytes = uint8ArrayFromString(encoded, 'base64url');
|
|
942
|
+
const json = new TextDecoder().decode(bytes);
|
|
943
|
+
return JSON.parse(json) as ControlNetworkSeed;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
948
|
+
*
|
|
949
|
+
* Sender hardening: the whole exchange — dial, write, ack read — is bounded by
|
|
950
|
+
* {@link seedDeliverTimeoutMs}, and the ack is capped at {@link MAX_SEED_SIZE}.
|
|
951
|
+
* The target is a NOT-YET-TRUSTED node the instigator chose to dial during
|
|
952
|
+
* onboarding, so an unbounded read here is strictly more exposed than the
|
|
953
|
+
* membership-gated receiver paths: without the bound a target that accepts the
|
|
954
|
+
* stream and never replies parks this call forever, and one that streams
|
|
955
|
+
* arbitrary bytes as a fake ack exhausts memory.
|
|
956
|
+
*/
|
|
957
|
+
async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<SeedAckMessage> {
|
|
958
|
+
if (!this.libp2pNode) {
|
|
959
|
+
throw new Error('Service not initialized');
|
|
960
|
+
}
|
|
961
|
+
// Capture the node so the closure below needs no non-null assertion.
|
|
962
|
+
const node = this.libp2pNode;
|
|
963
|
+
const addr = multiaddr(targetMultiaddr);
|
|
964
|
+
|
|
965
|
+
log('Delivering seed to: %s', targetMultiaddr);
|
|
966
|
+
|
|
967
|
+
return await withDeadline(
|
|
968
|
+
this.seedDeliverTimeoutMs,
|
|
969
|
+
`Seed delivery to ${targetMultiaddr}`,
|
|
970
|
+
(signal) => this.sendSeed(node, addr, seed, signal),
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* Open one stream to the target, send the seed frame, half-close, and read the ack.
|
|
976
|
+
*
|
|
977
|
+
* `signal` is the deadline from {@link deliverSeed}: it goes to `dialProtocol` so
|
|
978
|
+
* a timeout during connect aborts the dial, and into {@link exchangeFrame} so a
|
|
979
|
+
* timeout after the stream is open resets it — releasing the otherwise unbounded
|
|
980
|
+
* ack-read.
|
|
981
|
+
*
|
|
982
|
+
* Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
|
|
983
|
+
* tiny frame over a relay, whereas a seed is up to 1MB and this delivery path
|
|
984
|
+
* does not dial relay addresses today. Changing that is a separate decision.
|
|
985
|
+
*/
|
|
986
|
+
private async sendSeed(
|
|
987
|
+
node: Libp2p,
|
|
988
|
+
addr: Multiaddr,
|
|
989
|
+
seed: ControlNetworkSeed,
|
|
990
|
+
signal: AbortSignal,
|
|
991
|
+
): Promise<SeedAckMessage> {
|
|
992
|
+
const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
|
|
993
|
+
|
|
994
|
+
const message: SeedMessage = {
|
|
995
|
+
partyId: seed.partyId,
|
|
996
|
+
peers: seed.peers,
|
|
997
|
+
signature: seed.signature,
|
|
998
|
+
signerKey: seed.signerKey,
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
const ack = await exchangeFrame(
|
|
1002
|
+
rawStream as unknown as ControlStream,
|
|
1003
|
+
signal,
|
|
1004
|
+
message,
|
|
1005
|
+
(stream) => this.readSeedAck(stream),
|
|
1006
|
+
'Seed delivery aborted by timeout',
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
log('Seed delivery response: accepted=%s', ack.accepted);
|
|
1010
|
+
return ack;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
/**
|
|
1014
|
+
* Read the ack frame a delivery target writes back, bounded by
|
|
1015
|
+
* {@link seedDeliverTimeoutMs} and capped at {@link MAX_SEED_SIZE} — an
|
|
1016
|
+
* untrusted target must not be able to stream unlimited bytes as a fake ack.
|
|
1017
|
+
* Decoding runs inside {@link exchangeFrame}'s `try`, so a malformed or
|
|
1018
|
+
* non-JSON ack resets the stream rather than leaking it.
|
|
1019
|
+
*/
|
|
1020
|
+
private async readSeedAck(stream: ControlStream): Promise<SeedAckMessage> {
|
|
1021
|
+
const data = await readStreamToEnd(stream, {
|
|
1022
|
+
maxBytes: MAX_SEED_SIZE,
|
|
1023
|
+
timeoutMs: this.seedDeliverTimeoutMs,
|
|
1024
|
+
label: 'Seed ack',
|
|
1025
|
+
});
|
|
1026
|
+
const body = decodeLengthPrefixedFrame(data, MAX_SEED_SIZE);
|
|
1027
|
+
return JSON.parse(new TextDecoder().decode(body)) as SeedAckMessage;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Get this node's circuit relay address for inclusion in seeds.
|
|
1032
|
+
* Returns null if no relay address is available.
|
|
1033
|
+
*/
|
|
1034
|
+
async getRelayAddress(): Promise<string | null> {
|
|
1035
|
+
if (!this.libp2pNode) {
|
|
1036
|
+
return null;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const addrs = this.libp2pNode.getMultiaddrs();
|
|
1040
|
+
|
|
1041
|
+
// Find a circuit relay address
|
|
1042
|
+
const relayAddr = addrs.find(addr => addr.toString().includes('/p2p-circuit/'));
|
|
1043
|
+
|
|
1044
|
+
return relayAddr?.toString() ?? null;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Validate a seed's signature.
|
|
1049
|
+
*/
|
|
1050
|
+
validateSeedSignature(seed: ControlNetworkSeed): boolean {
|
|
1051
|
+
try {
|
|
1052
|
+
// Reconstruct the signed bytes via the shared canonical payload builder so
|
|
1053
|
+
// verification is independent of key order. The payload is the fixed
|
|
1054
|
+
// `{ partyId, peers }` the producer emits.
|
|
1055
|
+
const seedJson = canonicalSeedPayload(seed);
|
|
1056
|
+
const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
|
|
1057
|
+
|
|
1058
|
+
return verify(
|
|
1059
|
+
seedDigest,
|
|
1060
|
+
seed.signature,
|
|
1061
|
+
seed.signerKey,
|
|
1062
|
+
'ed25519',
|
|
1063
|
+
'base64url',
|
|
1064
|
+
'base64url',
|
|
1065
|
+
'base64url'
|
|
1066
|
+
);
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
log('Seed signature validation failed: %o', error);
|
|
1069
|
+
return false;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Query peers from the control database.
|
|
1075
|
+
*
|
|
1076
|
+
* Owner identity is sourced from the `OwnerKey` table, not from the
|
|
1077
|
+
* transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
|
|
1078
|
+
* multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
|
|
1079
|
+
* peer is an owner iff that derived key is in the `OwnerKey` set.
|
|
1080
|
+
* This makes any owner node markable — not just the local one — and ties
|
|
1081
|
+
* `isOwner` to the control table rather than to `peerId === self`.
|
|
1082
|
+
*
|
|
1083
|
+
* Read through {@link ControlDatabase.queryCadrePeers}, not a raw `CadrePeer`
|
|
1084
|
+
* select: that reader drops any row whose `StampId` is retired in
|
|
1085
|
+
* `CadreControl.Revocation`, so a removed member's addresses are never packed
|
|
1086
|
+
* into a seed and pushed into a joiner's peerstore (`applySeed` adds every
|
|
1087
|
+
* seed peer's addrs and dials the owner-flagged ones). A revoked peer is off
|
|
1088
|
+
* the addressable surface everywhere, and this is one of its exits.
|
|
1089
|
+
*
|
|
1090
|
+
* NOTE: this is the one owner lookup deliberately left on the REPLICATED
|
|
1091
|
+
* table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
|
|
1092
|
+
* — the receiver dials owner-flagged peers first — not a trust decision, and
|
|
1093
|
+
* the receiver re-derives real trust from its own anchor. So a polluted table
|
|
1094
|
+
* costs at most a wasted dial, while anchoring here would silently drop
|
|
1095
|
+
* legitimate co-owners this node never pinned. If `isOwner` ever gates
|
|
1096
|
+
* anything the receiver TRUSTS, move it to the anchor.
|
|
1097
|
+
*/
|
|
1098
|
+
private async queryPeers(): Promise<SeedPeer[]> {
|
|
1099
|
+
if (!this.controlDatabase) {
|
|
1100
|
+
return [];
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const ownerKeys = await this.controlDatabase.getOwnerKeys();
|
|
1104
|
+
const rows = await this.controlDatabase.queryCadrePeers();
|
|
1105
|
+
|
|
1106
|
+
return rows.map(({ peerId, multiaddr }) => {
|
|
1107
|
+
// Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
|
|
1108
|
+
// unparsable id yields null and is treated as a non-owner rather than
|
|
1109
|
+
// failing the whole seed creation.
|
|
1110
|
+
const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
|
|
1111
|
+
const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
|
|
1112
|
+
|
|
1113
|
+
return {
|
|
1114
|
+
peerId,
|
|
1115
|
+
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
1116
|
+
isOwner,
|
|
1117
|
+
...(isOwner ? { publicKey: pubKeyB64 } : {}),
|
|
1118
|
+
};
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Register the seed protocol handler. The inbound closure just delegates to
|
|
1124
|
+
* {@link handleSeedStream} — extracted as a method so it has a unit-test seam
|
|
1125
|
+
* (mirroring wake's `handleStream`) the inline closure never had.
|
|
1126
|
+
*/
|
|
1127
|
+
private async registerProtocolHandler(libp2pNode: Libp2p): Promise<void> {
|
|
1128
|
+
await libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
|
|
1129
|
+
const remotePeerId = (rawConnection as Connection).remotePeer.toString();
|
|
1130
|
+
await this.handleSeedStream(rawStream as ControlStream, remotePeerId);
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
log('Registered seed protocol handler: %s', SEED_PROTOCOL);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* Read one inbound seed frame, verify and merge it, write the ack and close the
|
|
1138
|
+
* stream, then dial the seed's owners.
|
|
1139
|
+
*
|
|
1140
|
+
* The ack and the close come BEFORE the owner dials: an unreachable owner can hold
|
|
1141
|
+
* a dial for `dialBudget.totalMs` (86 s at the default declared link), which is not
|
|
1142
|
+
* link time and does not belong inside the sender's delivery deadline. The close is
|
|
1143
|
+
* what releases the sender, which reads the ack to end-of-stream. The stream stays
|
|
1144
|
+
* counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
|
|
1145
|
+
* still bounds concurrent owner-dial phases.
|
|
1146
|
+
*
|
|
1147
|
+
* Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
|
|
1148
|
+
* {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
|
|
1149
|
+
* that never half-closes is aborted inside `readStreamToEnd`), and the existing
|
|
1150
|
+
* malformed/oversized-frame guard — all reported as a non-accepting
|
|
1151
|
+
* {@link SeedAckMessage} rather than a dropped/hung stream.
|
|
1152
|
+
*
|
|
1153
|
+
* NOTE: a receiver configured with an interactive trust-on-first-use policy asks a
|
|
1154
|
+
* human before acking, and that wait sits inside the sender's delivery deadline. If
|
|
1155
|
+
* TOFU is ever used on this wire path, ack "pending" or move the confirmation out of
|
|
1156
|
+
* the exchange.
|
|
1157
|
+
*/
|
|
1158
|
+
private async handleSeedStream(stream: ControlStream, remotePeerId: string): Promise<void> {
|
|
1159
|
+
log('Incoming seed delivery from: %s', remotePeerId);
|
|
1160
|
+
|
|
1161
|
+
if (this.activeStreams >= this.maxConcurrentSeeds) {
|
|
1162
|
+
log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
|
|
1163
|
+
await replyAndClose(stream, { accepted: false, reason: 'Too many concurrent seed deliveries' } satisfies SeedAckMessage, 'Seed');
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
this.activeStreams++;
|
|
1168
|
+
let acked = false;
|
|
1169
|
+
try {
|
|
1170
|
+
const seed = await this.readSeedFrame(stream);
|
|
1171
|
+
this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
|
|
1172
|
+
|
|
1173
|
+
const merged = await this.verifyAndMergeSeed(seed);
|
|
1174
|
+
acked = true;
|
|
1175
|
+
await replyAndClose(stream, { accepted: merged.success, reason: merged.error } satisfies SeedAckMessage, 'Seed');
|
|
1176
|
+
|
|
1177
|
+
if (merged.success) {
|
|
1178
|
+
await this.dialSeedOwners(seed);
|
|
1179
|
+
this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
|
|
1180
|
+
} else {
|
|
1181
|
+
this.eventCallbacks.onSeedError?.(seed.partyId, merged.error ?? 'Unknown error');
|
|
1182
|
+
}
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
log('Error handling seed delivery: %o', error);
|
|
1185
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
|
1186
|
+
this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
|
|
1187
|
+
// Once acked the stream is closed, so a later failure has no reply to make.
|
|
1188
|
+
if (!acked) {
|
|
1189
|
+
await replyAndClose(stream, { accepted: false, reason: errorMessage } satisfies SeedAckMessage, 'Seed');
|
|
1190
|
+
}
|
|
1191
|
+
} finally {
|
|
1192
|
+
this.activeStreams--;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** Read the inbound seed frame to EOF (bounded and size-capped) and decode it. */
|
|
1197
|
+
private async readSeedFrame(stream: ControlStream): Promise<ControlNetworkSeed> {
|
|
1198
|
+
const data = await readStreamToEnd(stream, {
|
|
1199
|
+
maxBytes: MAX_SEED_SIZE,
|
|
1200
|
+
timeoutMs: this.seedReadTimeoutMs,
|
|
1201
|
+
label: 'Seed',
|
|
1202
|
+
});
|
|
1203
|
+
const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data))) as SeedMessage;
|
|
1204
|
+
return {
|
|
1205
|
+
partyId: message.partyId,
|
|
1206
|
+
peers: message.peers,
|
|
1207
|
+
signature: message.signature,
|
|
1208
|
+
signerKey: message.signerKey,
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Shutdown the service.
|
|
1214
|
+
*/
|
|
1215
|
+
async shutdown(): Promise<void> {
|
|
1216
|
+
if (this.libp2pNode) {
|
|
1217
|
+
await this.libp2pNode.unhandle(SEED_PROTOCOL);
|
|
1218
|
+
}
|
|
1219
|
+
this.libp2pNode = null;
|
|
1220
|
+
this.controlDatabase = null;
|
|
1221
|
+
log('SeedBootstrapService shutdown');
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// ============================================================================
|
|
1225
|
+
// Helper Functions for Common Scenarios
|
|
1226
|
+
// ============================================================================
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Add a drone to the cadre (phone/server adds provider-hosted node).
|
|
1230
|
+
*
|
|
1231
|
+
* Use this when you've spawned a drone via provider API and received its
|
|
1232
|
+
* peer ID and multiaddrs. This method:
|
|
1233
|
+
* 1. Authorizes the drone peer
|
|
1234
|
+
* 2. Creates a seed including all current peers
|
|
1235
|
+
* 3. Returns the seed for sending to provider API
|
|
1236
|
+
*
|
|
1237
|
+
* Nothing here dials the drone or remembers its addresses beyond the unsigned
|
|
1238
|
+
* `CadrePeer` row, which no resolver accepts. `CadreNode.addDrone` is the entry
|
|
1239
|
+
* point that also retains them as a durable dial target, so the adder's
|
|
1240
|
+
* reconcile pass can open the connection — the drone cannot dial an owner that
|
|
1241
|
+
* does not listen. Call `CadreNode.reconcileControlCohort()` after delivering
|
|
1242
|
+
* the seed to dial at once.
|
|
1243
|
+
*
|
|
1244
|
+
* @param options - Drone peer info from provider API
|
|
1245
|
+
* @returns Seed and encoded seed for drone initialization
|
|
1246
|
+
*/
|
|
1247
|
+
async addDrone(options: AddDroneOptions): Promise<DroneInitResult> {
|
|
1248
|
+
const { dronePeerId, droneMultiaddrs } = options;
|
|
1249
|
+
|
|
1250
|
+
log('Adding drone: %s', dronePeerId);
|
|
1251
|
+
|
|
1252
|
+
// 1. Authorize the new drone peer
|
|
1253
|
+
await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs });
|
|
1254
|
+
|
|
1255
|
+
// 2. Create seed with current state
|
|
1256
|
+
const seed = await this.createSeed();
|
|
1257
|
+
|
|
1258
|
+
// 3. Encode for transport
|
|
1259
|
+
const encodedSeed = this.encodeSeed(seed);
|
|
1260
|
+
|
|
1261
|
+
log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
|
|
1262
|
+
|
|
1263
|
+
return { seed, encodedSeed };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Create an invite for a phone to join the cadre.
|
|
1268
|
+
*
|
|
1269
|
+
* Use this when a server (public IP) wants to invite a phone (NAT'd).
|
|
1270
|
+
* The invite is shared out-of-band (QR code, link, etc.) and contains
|
|
1271
|
+
* the server's address so the phone can dial in.
|
|
1272
|
+
*
|
|
1273
|
+
* @param token - Optional invite token for validation
|
|
1274
|
+
* @param expiresIn - Optional expiration time in milliseconds
|
|
1275
|
+
* @returns Invite and encoded invite for sharing
|
|
1276
|
+
*/
|
|
1277
|
+
async createInvite(token?: string, expiresIn?: number): Promise<InviteResult> {
|
|
1278
|
+
if (!this.libp2pNode) {
|
|
1279
|
+
throw new Error('Service not initialized');
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
log('Creating invite for phone');
|
|
1283
|
+
|
|
1284
|
+
// Get this node's dialable addresses. When an inviteAddressResolver is
|
|
1285
|
+
// configured (typically by `@serfab/cadre-host`'s NatService), it takes
|
|
1286
|
+
// priority — it may substitute a DDNS hostname and externally-mapped
|
|
1287
|
+
// port for the raw LAN multiaddrs libp2p reports.
|
|
1288
|
+
let ownerAddrs: string[];
|
|
1289
|
+
if (this.config.inviteAddressResolver) {
|
|
1290
|
+
try {
|
|
1291
|
+
ownerAddrs = await this.config.inviteAddressResolver();
|
|
1292
|
+
} catch (err) {
|
|
1293
|
+
log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
|
|
1294
|
+
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
1295
|
+
}
|
|
1296
|
+
} else {
|
|
1297
|
+
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
// Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
|
|
1301
|
+
// the trusted owner set before applying any seed.
|
|
1302
|
+
//
|
|
1303
|
+
// Sourced ONLY from this node's own anchor, never from the replicated
|
|
1304
|
+
// OwnerKey table: the invitee anchors whatever arrives here
|
|
1305
|
+
// (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
|
|
1306
|
+
// pollutable table would let a stranger's genesis-inserted key ride an
|
|
1307
|
+
// otherwise-legitimate invite straight into the new node's anchor —
|
|
1308
|
+
// poisoning the very store this whole trust chain rests on. No anchor wired
|
|
1309
|
+
// (a directly-constructed service) means no pins to hand out: an invite
|
|
1310
|
+
// without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
|
|
1311
|
+
// table-sourced one silently hands it an unanchored key.
|
|
1312
|
+
const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
|
|
1313
|
+
|
|
1314
|
+
const now = Date.now();
|
|
1315
|
+
const invite: CadreInvite = {
|
|
1316
|
+
partyId: this.config.partyId,
|
|
1317
|
+
ownerAddrs,
|
|
1318
|
+
ownerKeys: ownerKeys.length ? ownerKeys : undefined,
|
|
1319
|
+
token,
|
|
1320
|
+
createdAt: now,
|
|
1321
|
+
expiresAt: expiresIn ? now + expiresIn : undefined,
|
|
1322
|
+
};
|
|
1323
|
+
|
|
1324
|
+
const encodedInvite = this.encodeInvite(invite);
|
|
1325
|
+
|
|
1326
|
+
log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
|
|
1327
|
+
|
|
1328
|
+
return { invite, encodedInvite };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Accept a phone connection using an invite.
|
|
1333
|
+
*
|
|
1334
|
+
* Use this when a phone dials in with an invite token. This method:
|
|
1335
|
+
* 1. Validates the token if provided
|
|
1336
|
+
* 2. Authorizes the phone peer
|
|
1337
|
+
*
|
|
1338
|
+
* After this, the phone can sync the control database normally.
|
|
1339
|
+
*
|
|
1340
|
+
* @param options - Phone peer info and invite token
|
|
1341
|
+
* @param issuedInvite - The original invite for validation
|
|
1342
|
+
*/
|
|
1343
|
+
async acceptPhone(options: AddPhoneOptions, issuedInvite?: CadreInvite): Promise<void> {
|
|
1344
|
+
const { phonePeerId, token } = options;
|
|
1345
|
+
|
|
1346
|
+
log('Accepting phone: %s', phonePeerId);
|
|
1347
|
+
|
|
1348
|
+
// Validate token if invite provided
|
|
1349
|
+
if (issuedInvite) {
|
|
1350
|
+
if (issuedInvite.token && issuedInvite.token !== token) {
|
|
1351
|
+
throw new Error('Invalid invite token');
|
|
1352
|
+
}
|
|
1353
|
+
if (issuedInvite.expiresAt && Date.now() > issuedInvite.expiresAt) {
|
|
1354
|
+
throw new Error('Invite has expired');
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// Authorize the phone peer (no multiaddrs - phone is NAT'd)
|
|
1359
|
+
await this.authorizePeer({ peerId: phonePeerId });
|
|
1360
|
+
|
|
1361
|
+
log('Phone %s accepted and authorized', phonePeerId);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* Add a phone to the cadre with relay support.
|
|
1366
|
+
*
|
|
1367
|
+
* Use this when both nodes are NAT'd (phone-to-phone). This method:
|
|
1368
|
+
* 1. Authorizes the new phone peer
|
|
1369
|
+
* 2. Creates a seed with relay addresses for dialing
|
|
1370
|
+
*
|
|
1371
|
+
* @param phonePeerId - Peer ID of the new phone
|
|
1372
|
+
* @returns Seed with relay addresses for out-of-band delivery
|
|
1373
|
+
*/
|
|
1374
|
+
async addPhoneWithRelay(phonePeerId: string): Promise<DroneInitResult> {
|
|
1375
|
+
log('Adding phone with relay: %s', phonePeerId);
|
|
1376
|
+
|
|
1377
|
+
// 1. Authorize the new phone peer (no multiaddrs - NAT'd)
|
|
1378
|
+
await this.authorizePeer({ peerId: phonePeerId });
|
|
1379
|
+
|
|
1380
|
+
// 2. Get relay address for this node
|
|
1381
|
+
const relayAddr = await this.getRelayAddress();
|
|
1382
|
+
|
|
1383
|
+
// 3. Create seed - will include our relay address if available
|
|
1384
|
+
const seed = await this.createSeed();
|
|
1385
|
+
|
|
1386
|
+
// If we have a relay address, make sure it's in our peer entry
|
|
1387
|
+
if (relayAddr && this.libp2pNode) {
|
|
1388
|
+
const ourPeerId = this.libp2pNode.peerId.toString();
|
|
1389
|
+
const ourPeer = seed.peers.find(p => p.peerId === ourPeerId);
|
|
1390
|
+
if (ourPeer && !ourPeer.multiaddrs.includes(relayAddr)) {
|
|
1391
|
+
ourPeer.multiaddrs.push(relayAddr);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const encodedSeed = this.encodeSeed(seed);
|
|
1396
|
+
|
|
1397
|
+
log('Phone %s added with relay, seed created', phonePeerId);
|
|
1398
|
+
|
|
1399
|
+
return { seed, encodedSeed };
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Encode an invite for out-of-band delivery.
|
|
1404
|
+
*/
|
|
1405
|
+
encodeInvite(invite: CadreInvite): string {
|
|
1406
|
+
const json = JSON.stringify(invite);
|
|
1407
|
+
return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* Decode an invite from base64url encoding.
|
|
1412
|
+
*/
|
|
1413
|
+
decodeInvite(encoded: string): CadreInvite {
|
|
1414
|
+
const bytes = uint8ArrayFromString(encoded, 'base64url');
|
|
1415
|
+
const json = new TextDecoder().decode(bytes);
|
|
1416
|
+
return JSON.parse(json) as CadreInvite;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Dial an owner from an invite.
|
|
1421
|
+
* Use this on a phone after receiving an invite to connect to the owner.
|
|
1422
|
+
*
|
|
1423
|
+
* @param invite - The invite received out-of-band
|
|
1424
|
+
* @returns Connection to the owner
|
|
1425
|
+
*/
|
|
1426
|
+
async dialInvite(invite: CadreInvite): Promise<void> {
|
|
1427
|
+
if (!this.libp2pNode) {
|
|
1428
|
+
throw new Error('Service not initialized');
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// Check expiration
|
|
1432
|
+
if (invite.expiresAt && Date.now() > invite.expiresAt) {
|
|
1433
|
+
throw new Error('Invite has expired');
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
|
|
1437
|
+
|
|
1438
|
+
const addrs = parseDialAddrs(invite.ownerAddrs);
|
|
1439
|
+
if (addrs.length === 0) {
|
|
1440
|
+
throw new Error('No owner addresses available');
|
|
1441
|
+
}
|
|
1442
|
+
// Each address on its own time limit, so one that never answers cannot hold
|
|
1443
|
+
// the invitee back from the rest (`dialPeerAddrs`).
|
|
1444
|
+
const connection = await dialPeerAddrs(this.libp2pNode, addrs, this.dialBudget, 'Invite owner dial');
|
|
1445
|
+
log('Connected to owner at: %s', connection.remoteAddr.toString());
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
|