@serfab/cadre-core 1.13.0 → 1.14.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-invite-protocol.d.ts +348 -0
- package/dist/cadre-invite-protocol.js +605 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- package/dist/cadre-node.d.ts +523 -180
- package/dist/cadre-node.js +921 -293
- package/dist/cadre-node.js.map +1 -1
- package/dist/claim-proof.d.ts +45 -0
- package/dist/claim-proof.js +132 -0
- package/dist/claim-proof.js.map +1 -0
- package/dist/control-authorization.d.ts +80 -19
- package/dist/control-authorization.js +55 -13
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +514 -97
- package/dist/control-database.js +1033 -195
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +142 -0
- package/dist/control-protocol-guard.js +293 -0
- package/dist/control-protocol-guard.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +801 -1067
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +4 -0
- package/dist/control-stream.js +22 -7
- package/dist/control-stream.js.map +1 -1
- package/dist/delegate-admission.d.ts +1 -1
- package/dist/delegate-admission.js +1 -1
- package/dist/device-token.d.ts +11 -9
- package/dist/device-token.js +14 -12
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +15 -0
- package/dist/ed25519-key.js +28 -1
- package/dist/ed25519-key.js.map +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.js +27 -9
- package/dist/index.js.map +1 -1
- package/dist/link-budget.d.ts +16 -29
- package/dist/link-budget.js +16 -31
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +190 -151
- package/dist/membership-connection-gater.js +235 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +19 -0
- package/dist/node-claim-payload.js +120 -0
- package/dist/node-claim-payload.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +22 -0
- package/dist/owner-anchor-sync.js +143 -0
- package/dist/owner-anchor-sync.js.map +1 -0
- package/dist/peer-authorization.d.ts +113 -18
- package/dist/peer-authorization.js +225 -29
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-dial.d.ts +19 -0
- package/dist/peer-dial.js +22 -1
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +54 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +13 -9
- package/dist/peer-record.js +16 -12
- package/dist/peer-record.js.map +1 -1
- package/dist/pending-join-runner.d.ts +66 -58
- package/dist/pending-join-runner.js +136 -145
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/push-fanout.d.ts +8 -2
- package/dist/push-fanout.js +8 -2
- package/dist/push-fanout.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +147 -122
- package/dist/seed-bootstrap.js +284 -269
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +98 -37
- package/dist/seed-trust-policy.js +188 -2
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +24 -26
- package/dist/strand-addr-protocol.js +19 -26
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-formation-protocol.js +1 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-revocation-enforcer.d.ts +11 -11
- package/dist/strand-revocation-enforcer.js +11 -11
- package/dist/strand-wake-protocol.d.ts +13 -16
- package/dist/strand-wake-protocol.js +13 -20
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/trusted-owner-store.d.ts +35 -5
- package/dist/trusted-owner-store.js +50 -8
- package/dist/trusted-owner-store.js.map +1 -1
- package/dist/types.d.ts +302 -87
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- package/src/cadre-invite-protocol.ts +864 -0
- package/src/cadre-node.ts +9381 -8686
- package/src/claim-proof.ts +147 -0
- package/src/control-authorization.ts +203 -117
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +1216 -218
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +812 -1078
- package/src/control-stream.ts +26 -7
- package/src/delegate-admission.ts +1 -1
- package/src/device-token.ts +14 -10
- package/src/ed25519-key.ts +28 -1
- package/src/index.ts +84 -10
- package/src/link-budget.ts +16 -32
- package/src/membership-connection-gater.ts +637 -570
- package/src/node-claim-payload.ts +125 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +22 -1
- package/src/peer-join-backfill.ts +56 -11
- package/src/peer-record.ts +16 -11
- package/src/pending-join-runner.ts +729 -735
- package/src/push-fanout.ts +8 -2
- package/src/seed-bootstrap.ts +344 -321
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- package/src/strand-formation-protocol.ts +1 -1
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +13 -23
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2471 -2242
package/src/seed-bootstrap.ts
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
3
3
|
import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
|
|
4
|
-
import type { Libp2p, Connection } from '@libp2p/interface';
|
|
4
|
+
import type { Libp2p, Connection, PeerId, Stream } from '@libp2p/interface';
|
|
5
5
|
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
6
6
|
import { peerIdFromString } from '@libp2p/peer-id';
|
|
7
7
|
import { type ControlStream, withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
dialPeerAddrs,
|
|
10
|
+
SelfRelayOnlyError,
|
|
11
|
+
PeerUnreachableError,
|
|
12
|
+
DEFAULT_PEER_DIAL_BUDGET,
|
|
13
|
+
type PeerDialBudget,
|
|
14
|
+
} from './peer-dial.js';
|
|
15
|
+
import { withTrailingPeerId } from './peer-record.js';
|
|
9
16
|
import { relayedRequestBudgetMs } from './link-budget.js';
|
|
10
17
|
import type {
|
|
11
18
|
ControlNetworkSeed,
|
|
12
19
|
SeedPeer,
|
|
13
20
|
SeedMessage,
|
|
14
21
|
SeedAckMessage,
|
|
22
|
+
SeedRefusalCode,
|
|
23
|
+
SeedDeliveryTarget,
|
|
15
24
|
AuthorizePeerOptions,
|
|
16
25
|
ApplySeedResult,
|
|
17
26
|
AddDroneOptions,
|
|
18
|
-
AddPhoneOptions,
|
|
19
27
|
DroneInitResult,
|
|
20
|
-
|
|
21
|
-
CadreInvite,
|
|
28
|
+
CadreInviteRow,
|
|
22
29
|
PeerAddressRecord,
|
|
23
30
|
DeviceTokenRecord,
|
|
24
31
|
RevocationRow,
|
|
@@ -28,6 +35,7 @@ import type { ControlDatabase } from './control-database.js';
|
|
|
28
35
|
import { generateStampId } from './control-database.js';
|
|
29
36
|
import { canonicalJson } from './canonical-json.js';
|
|
30
37
|
import { deviceTokenAddDigest } from './peer-authorization.js';
|
|
38
|
+
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
31
39
|
import {
|
|
32
40
|
type SeedTrustPolicy,
|
|
33
41
|
type SeedTrustDecision,
|
|
@@ -79,28 +87,6 @@ export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED
|
|
|
79
87
|
return data.subarray(4, 4 + length);
|
|
80
88
|
}
|
|
81
89
|
|
|
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
90
|
/**
|
|
105
91
|
* Canonical byte representation of the authenticated seed fields.
|
|
106
92
|
*
|
|
@@ -116,12 +102,39 @@ export function canonicalSeedPayload(
|
|
|
116
102
|
return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
|
|
117
103
|
}
|
|
118
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The digest a seed's signature covers: sha256 over {@link canonicalSeedPayload},
|
|
107
|
+
* base64url. One function so the signer (`createSeed`), the verifier
|
|
108
|
+
* (`validateSeedSignature`) and the claim proof (`claim-proof.ts`, which binds a
|
|
109
|
+
* proof to one seed through this value) all name the same bytes.
|
|
110
|
+
*/
|
|
111
|
+
export function seedDigest(seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>): string {
|
|
112
|
+
return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url') as string;
|
|
113
|
+
}
|
|
114
|
+
|
|
119
115
|
/**
|
|
120
116
|
* An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
|
|
121
117
|
* so the dial counters read zero rather than being absent.
|
|
122
118
|
*/
|
|
123
|
-
function seedRejected(error: string): ApplySeedResult {
|
|
124
|
-
return {
|
|
119
|
+
function seedRejected(error: string, code?: SeedRefusalCode): ApplySeedResult {
|
|
120
|
+
return {
|
|
121
|
+
success: false,
|
|
122
|
+
peersAdded: 0,
|
|
123
|
+
error,
|
|
124
|
+
...(code !== undefined ? { code } : {}),
|
|
125
|
+
ownerDialsAttempted: 0,
|
|
126
|
+
ownerDialsFailed: 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** What rides beside a seed into {@link SeedBootstrapService.verifyAndMergeSeed}. */
|
|
131
|
+
interface SeedApplyOptions {
|
|
132
|
+
/** Per-call policy override, used instead of the service-configured default for this seed only. */
|
|
133
|
+
trustPolicy?: SeedTrustPolicy;
|
|
134
|
+
/** The claim proof the message carried, when the seed came over the wire. */
|
|
135
|
+
claimProof?: string;
|
|
136
|
+
/** The delivering peer, when the seed came over the wire. */
|
|
137
|
+
remotePeerId?: string;
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
/** What dialing a seed's owner peers produced — the owner-dial half of an {@link ApplySeedResult}. */
|
|
@@ -143,6 +156,60 @@ function parseDialAddrs(addrs: readonly string[]): Multiaddr[] {
|
|
|
143
156
|
return parsed;
|
|
144
157
|
}
|
|
145
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Merge a peer list's addresses into `node`'s peer store, so the node can dial them: the
|
|
161
|
+
* seed's peers after its signature and trust checks, the dial hints a member returns on
|
|
162
|
+
* a cadre invitation redemption (`cadre-invite-protocol.ts`), or the node a seed is being
|
|
163
|
+
* delivered to ({@link SeedBootstrapService.deliverSeed}). Best-effort per peer — one
|
|
164
|
+
* unparsable entry costs that peer, not the rest — and a peer with no address is skipped,
|
|
165
|
+
* since there is nothing to dial. Returns how many peers were merged.
|
|
166
|
+
*/
|
|
167
|
+
export async function mergeSeedPeers(node: Libp2p, peers: readonly SeedPeer[]): Promise<number> {
|
|
168
|
+
let peersAdded = 0;
|
|
169
|
+
for (const peer of peers) {
|
|
170
|
+
if (peer.multiaddrs.length === 0) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const peerId = peerIdFromString(peer.peerId);
|
|
175
|
+
const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
|
|
176
|
+
await node.peerStore.merge(peerId, { multiaddrs: addrs });
|
|
177
|
+
peersAdded++;
|
|
178
|
+
log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
log('Failed to add peer %s: %o', peer.peerId, error);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return peersAdded;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Opens a seed delivery's stream under the delivery deadline's signal. */
|
|
187
|
+
type SeedStreamOpener = (signal: AbortSignal) => Promise<Stream>;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A delivery target's addresses, each bound to its peer id (`withTrailingPeerId`) so the dial
|
|
191
|
+
* authenticates the node it aims at rather than trusting whoever answers. An address that does
|
|
192
|
+
* not parse, or that names another peer, throws: that is the caller's mistake, not the node
|
|
193
|
+
* being unreachable.
|
|
194
|
+
*/
|
|
195
|
+
function deliveryDialAddrs(peerId: string, addrs: readonly string[]): Multiaddr[] {
|
|
196
|
+
return addrs.map((text) => {
|
|
197
|
+
const bound = withTrailingPeerId(multiaddr(text), peerId);
|
|
198
|
+
if (!bound) {
|
|
199
|
+
throw new Error(`Seed delivery to ${peerId}: address ${text} names another peer`);
|
|
200
|
+
}
|
|
201
|
+
return bound;
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* A connection to `peerId` that `node` already holds and a seed stream can use: open and without
|
|
207
|
+
* relay limits, the rule libp2p applies when `dialProtocol` reuses a connection.
|
|
208
|
+
*/
|
|
209
|
+
function openUnlimitedConnection(node: Libp2p, peerId: PeerId): Connection | undefined {
|
|
210
|
+
return node.getConnections(peerId).find((c) => c.status === 'open' && c.limits === undefined);
|
|
211
|
+
}
|
|
212
|
+
|
|
146
213
|
/**
|
|
147
214
|
* Configuration for the SeedBootstrapService
|
|
148
215
|
*/
|
|
@@ -153,13 +220,6 @@ export interface SeedBootstrapConfig {
|
|
|
153
220
|
ownerPrivateKey?: string;
|
|
154
221
|
/** Owner public key (base64url) - derived from private key if not provided */
|
|
155
222
|
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
223
|
/**
|
|
164
224
|
* Trust anchor for incoming seeds. Decides whether a signature-verified
|
|
165
225
|
* `signerKey` should be trusted, against the receiver's anchored owner
|
|
@@ -203,18 +263,20 @@ export interface SeedBootstrapConfig {
|
|
|
203
263
|
*/
|
|
204
264
|
linkRoundTripMs?: number;
|
|
205
265
|
/**
|
|
206
|
-
* Time {@link SeedBootstrapService.deliverSeed} waits for the
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
266
|
+
* Time {@link SeedBootstrapService.deliverSeed} waits for the request — opening the
|
|
267
|
+
* stream, write, ack read — before aborting (ms). For a multiaddr string, or a peer id
|
|
268
|
+
* with no addresses, opening the stream includes the dial; for a peer id with addresses
|
|
269
|
+
* the connection is formed first, outside this limit and within {@link dialBudget}.
|
|
270
|
+
* Bounds the SENDER against a seed target that accepts the stream and then never replies;
|
|
271
|
+
* the target is a not-yet-trusted node during onboarding, so this is the more exposed
|
|
210
272
|
* direction than the receiver knobs above.
|
|
211
273
|
*
|
|
212
274
|
* 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
|
-
*
|
|
215
|
-
* connection today; the relayed-dial count is the upper
|
|
216
|
-
* same choice `CadreNode.controlDialBudget` makes for every
|
|
217
|
-
* because the receiver acks before its owner dials.
|
|
275
|
+
* default declaration): one dial that may need a relay, then one request and its answer —
|
|
276
|
+
* sized for the forms that dial inside it. Delivery does not set `runOnLimitedConnection`,
|
|
277
|
+
* so it does not use a limited relayed connection today; the relayed-dial count is the upper
|
|
278
|
+
* bound on the dial it can use, the same choice `CadreNode.controlDialBudget` makes for every
|
|
279
|
+
* address. It holds only link work because the receiver acks before its owner dials.
|
|
218
280
|
*
|
|
219
281
|
* NOTE: no transfer allowance — a seed is a peer list of a few KB, and `MAX_SEED_SIZE` (1 MiB)
|
|
220
282
|
* is a defensive cap. If seeds ever grow toward that cap, add an allowance the way
|
|
@@ -224,8 +286,8 @@ export interface SeedBootstrapConfig {
|
|
|
224
286
|
/**
|
|
225
287
|
* Time limits for each peer this service dials from a list of addresses —
|
|
226
288
|
* {@link SeedBootstrapService.applySeed}'s owner dials and
|
|
227
|
-
* {@link SeedBootstrapService.
|
|
228
|
-
* `peer-dial.ts`). Defaults to {@link DEFAULT_PEER_DIAL_BUDGET}; a `CadreNode`
|
|
289
|
+
* {@link SeedBootstrapService.deliverSeed}'s dial of a peer id with addresses — per
|
|
290
|
+
* address and per peer (see `peer-dial.ts`). Defaults to {@link DEFAULT_PEER_DIAL_BUDGET}; a `CadreNode`
|
|
229
291
|
* passes its `network.controlCohort` limits.
|
|
230
292
|
*/
|
|
231
293
|
dialBudget?: PeerDialBudget;
|
|
@@ -320,10 +382,10 @@ export class SeedBootstrapService {
|
|
|
320
382
|
* `registerHandler` (default true) gates registration of the shared inbound
|
|
321
383
|
* `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
|
|
322
384
|
* (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
|
|
323
|
-
* it on. The throwaway temp
|
|
324
|
-
* `
|
|
325
|
-
*
|
|
326
|
-
*
|
|
385
|
+
* it on. The throwaway temp service CadreNode builds in `applySeed` passes
|
|
386
|
+
* `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
|
|
387
|
+
* dialing and known-key lookup, and must NOT bind a discarded closure to the
|
|
388
|
+
* shared node (a handler leak, and a second
|
|
327
389
|
* `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
|
|
328
390
|
*
|
|
329
391
|
* Rejects when libp2p refuses the registration; the node and database are
|
|
@@ -447,7 +509,7 @@ export class SeedBootstrapService {
|
|
|
447
509
|
*/
|
|
448
510
|
async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
|
|
449
511
|
const stampId = generateStampId(record.peerId);
|
|
450
|
-
const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
|
|
512
|
+
const signature = this.signDigest(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }));
|
|
451
513
|
if (!this.controlDatabase) {
|
|
452
514
|
throw new Error('Control database not initialized');
|
|
453
515
|
}
|
|
@@ -557,7 +619,7 @@ export class SeedBootstrapService {
|
|
|
557
619
|
*
|
|
558
620
|
* The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
|
|
559
621
|
* signature over the DISTINCT 'remove'-tagged digest
|
|
560
|
-
* `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
|
|
622
|
+
* `digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId)` by an owner
|
|
561
623
|
* key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
|
|
562
624
|
* never be replayed to delete.
|
|
563
625
|
*
|
|
@@ -606,6 +668,43 @@ export class SeedBootstrapService {
|
|
|
606
668
|
log('Peer %s removed successfully (stamp retired)', peerId);
|
|
607
669
|
}
|
|
608
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
|
|
673
|
+
* liveness check, the stamp, the signature and the membership notify are
|
|
674
|
+
* {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
|
|
675
|
+
* precondition, checked before any read (see {@link removePeer}).
|
|
676
|
+
*
|
|
677
|
+
* @returns `true` when the row was seated, `false` when the key is already a live owner.
|
|
678
|
+
*/
|
|
679
|
+
async addOwner(ownerPublicKey: string): Promise<boolean> {
|
|
680
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
681
|
+
if (!this.controlDatabase) {
|
|
682
|
+
throw new Error('Control database not initialized');
|
|
683
|
+
}
|
|
684
|
+
return await this.controlDatabase.insertOwnerKeyVouched(
|
|
685
|
+
ownerPublicKey, ownerKey, message => this.signMessageBytes(message),
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
|
|
691
|
+
* and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
|
|
692
|
+
* the last owner), the digests and the membership notify are
|
|
693
|
+
* {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
|
|
694
|
+
* checked before any read (see {@link removePeer}).
|
|
695
|
+
*
|
|
696
|
+
* @returns `true` when the row was removed, `false` when the key is not a live owner here.
|
|
697
|
+
*/
|
|
698
|
+
async removeOwner(ownerPublicKey: string): Promise<boolean> {
|
|
699
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
700
|
+
if (!this.controlDatabase) {
|
|
701
|
+
throw new Error('Control database not initialized');
|
|
702
|
+
}
|
|
703
|
+
return await this.controlDatabase.deleteOwnerKey(
|
|
704
|
+
ownerPublicKey, ownerKey, message => this.signMessageBytes(message),
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
|
|
609
708
|
/**
|
|
610
709
|
* Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
|
|
611
710
|
* re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
|
|
@@ -692,6 +791,42 @@ export class SeedBootstrapService {
|
|
|
692
791
|
);
|
|
693
792
|
}
|
|
694
793
|
|
|
794
|
+
/**
|
|
795
|
+
* Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
|
|
796
|
+
* on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
|
|
797
|
+
* expiry and use-count defaults, and the bundle the holder receives are
|
|
798
|
+
* `CadreNode.createCadreInvitation`'s; what stays here is the owner-key precondition and
|
|
799
|
+
* the signature, as {@link insertSelfDeviceToken} keeps them for device tokens.
|
|
800
|
+
*
|
|
801
|
+
* @returns the row as stored, which the invitation bundle carries verbatim.
|
|
802
|
+
* @throws if no owner private key is configured or the control database is not initialized.
|
|
803
|
+
*/
|
|
804
|
+
async insertCadreInvite(invite: Parameters<ControlDatabase['insertCadreInvite']>[0]): Promise<CadreInviteRow> {
|
|
805
|
+
// Fail fast on a keyless service before any DB work (see removePeer).
|
|
806
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
807
|
+
if (!this.controlDatabase) {
|
|
808
|
+
throw new Error('Control database not initialized');
|
|
809
|
+
}
|
|
810
|
+
return await this.controlDatabase.insertCadreInvite(invite, ownerKey, message => this.signMessageBytes(message));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
|
|
815
|
+
* the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
|
|
816
|
+
* owner-key precondition.
|
|
817
|
+
*
|
|
818
|
+
* @returns `true` when this call filed the tombstone, `false` when the row is absent here or
|
|
819
|
+
* already withdrawn.
|
|
820
|
+
* @throws if no owner private key is configured or the control database is not initialized.
|
|
821
|
+
*/
|
|
822
|
+
async withdrawCadreInvite(key: string): Promise<boolean> {
|
|
823
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
824
|
+
if (!this.controlDatabase) {
|
|
825
|
+
throw new Error('Control database not initialized');
|
|
826
|
+
}
|
|
827
|
+
return await this.controlDatabase.withdrawCadreInvite(key, ownerKey, message => this.signMessageBytes(message));
|
|
828
|
+
}
|
|
829
|
+
|
|
695
830
|
/**
|
|
696
831
|
* Create a seed from the current control network state.
|
|
697
832
|
* The seed contains peer information and is signed by an owner.
|
|
@@ -717,10 +852,8 @@ export class SeedBootstrapService {
|
|
|
717
852
|
};
|
|
718
853
|
|
|
719
854
|
// Sign the seed over its canonical byte representation
|
|
720
|
-
const seedJson = canonicalSeedPayload(seedData);
|
|
721
|
-
const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
|
|
722
855
|
const signature = sign(
|
|
723
|
-
seedDigest,
|
|
856
|
+
seedDigest(seedData),
|
|
724
857
|
this.config.ownerPrivateKey,
|
|
725
858
|
'ed25519',
|
|
726
859
|
'base64url',
|
|
@@ -751,11 +884,11 @@ export class SeedBootstrapService {
|
|
|
751
884
|
*
|
|
752
885
|
* A key accepted via a pin/TOFU is persisted into the anchor (the policy says
|
|
753
886
|
* so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
|
|
754
|
-
* anchored without re-supplying the
|
|
887
|
+
* anchored without re-supplying the pin.
|
|
755
888
|
*
|
|
756
889
|
* @param seed - The seed to apply (already transport-decoded).
|
|
757
890
|
* @param options.trustPolicy - Per-call policy override (e.g. a
|
|
758
|
-
* `pinnedKeyTrustPolicy`
|
|
891
|
+
* `pinnedKeyTrustPolicy` built from an operator pin) used instead of the
|
|
759
892
|
* service-configured default for this seed only.
|
|
760
893
|
*/
|
|
761
894
|
async applySeed(
|
|
@@ -776,22 +909,27 @@ export class SeedBootstrapService {
|
|
|
776
909
|
*/
|
|
777
910
|
private async verifyAndMergeSeed(
|
|
778
911
|
seed: ControlNetworkSeed,
|
|
779
|
-
options?:
|
|
912
|
+
options?: SeedApplyOptions
|
|
780
913
|
): Promise<ApplySeedResult> {
|
|
781
914
|
if (!this.libp2pNode) {
|
|
782
915
|
return seedRejected('Service not initialized');
|
|
783
916
|
}
|
|
784
917
|
|
|
785
|
-
// NOTE: `seed.partyId` is never checked against `config.partyId`.
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
918
|
+
// NOTE: `seed.partyId` is never checked against `config.partyId`. Trust is keyed on
|
|
919
|
+
// `signerKey` vs the anchor, and the anchor a stray-party seed could write into
|
|
920
|
+
// belongs to THIS party, which only a caller-supplied pin for that signer can reach.
|
|
921
|
+
// The one reader of the seed's party is the claim policy, which records it as the
|
|
922
|
+
// party the node now serves (`CadreNodeConfig.claim.record`): a mismatch is still not
|
|
923
|
+
// rejected there because an unclaimed node's own party is a placeholder, and the
|
|
924
|
+
// claim seed is the first thing to name the real one. If any other path ever
|
|
925
|
+
// branches on the seed's partyId (or the anchor becomes multi-party), reject a
|
|
790
926
|
// mismatch here instead.
|
|
791
927
|
log('Applying seed for party: %s', seed.partyId);
|
|
792
928
|
|
|
793
|
-
//
|
|
794
|
-
|
|
929
|
+
// One digest serves the signature check and the trust context: a claim proof is
|
|
930
|
+
// bound to the seed through the same value the signature covers.
|
|
931
|
+
const digestB64 = seedDigest(seed);
|
|
932
|
+
if (!this.validateSeedSignature(seed, digestB64)) {
|
|
795
933
|
return seedRejected('Invalid seed signature');
|
|
796
934
|
}
|
|
797
935
|
|
|
@@ -807,34 +945,20 @@ export class SeedBootstrapService {
|
|
|
807
945
|
partyId: seed.partyId,
|
|
808
946
|
signerKey: seed.signerKey,
|
|
809
947
|
knownOwnerKeys,
|
|
948
|
+
// Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
|
|
949
|
+
// omit `peerId`. A real node always has one, and a proof bound to a real peer id can
|
|
950
|
+
// only fail against '' — the safe direction.
|
|
951
|
+
localPeerId: this.libp2pNode.peerId?.toString() ?? '',
|
|
952
|
+
seedDigest: digestB64,
|
|
953
|
+
claimProof: options?.claimProof,
|
|
954
|
+
remotePeerId: options?.remotePeerId,
|
|
810
955
|
});
|
|
811
956
|
if (!decision.trusted) {
|
|
812
|
-
return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
|
|
957
|
+
return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy', decision.code);
|
|
813
958
|
}
|
|
814
959
|
await this.anchorAcceptedSigner(seed.signerKey, decision);
|
|
815
960
|
|
|
816
|
-
|
|
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
|
-
|
|
961
|
+
const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
|
|
838
962
|
log('Merged seed: %d peers added', peersAdded);
|
|
839
963
|
return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
840
964
|
}
|
|
@@ -897,7 +1021,7 @@ export class SeedBootstrapService {
|
|
|
897
1021
|
|
|
898
1022
|
/**
|
|
899
1023
|
* 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
|
|
1024
|
+
* later seed from the same owner is anchored without re-supplying the pin
|
|
901
1025
|
* or re-prompting. Only the policy decides this happens (`anchorAs` is unset
|
|
902
1026
|
* when the key was already anchored, so a plain re-apply writes nothing) and
|
|
903
1027
|
* `trust()` is idempotent, keeping the original provenance for a known key.
|
|
@@ -905,14 +1029,19 @@ export class SeedBootstrapService {
|
|
|
905
1029
|
* Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
|
|
906
1030
|
* in-memory anchor synchronously, so this seed and the rest of the session are
|
|
907
1031
|
* unaffected — only durability across a restart is lost, and that is logged.
|
|
1032
|
+
* That holds for pins and TOFU, which are re-supplied at the next start; a claim
|
|
1033
|
+
* is the opposite case, so `claimSecretTrustPolicy` anchors its signer itself,
|
|
1034
|
+
* awaits durability, and never sets `anchorAs` (the type excludes `'claim'`).
|
|
908
1035
|
*
|
|
909
1036
|
* NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
|
|
910
1037
|
* unauthorized to authorized, which the write-driven membership-gate refresh
|
|
911
|
-
* (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written.
|
|
912
|
-
*
|
|
913
|
-
* gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`)
|
|
914
|
-
*
|
|
915
|
-
*
|
|
1038
|
+
* (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. The anchor
|
|
1039
|
+
* is mutated from two places: here, on seed application, where both seed paths
|
|
1040
|
+
* refresh the gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`);
|
|
1041
|
+
* and by the anchor sync (`CadreNode.syncOwnerAnchor`), which runs INSIDE the
|
|
1042
|
+
* membership refresh, ahead of the membership read, so its changes are judged by
|
|
1043
|
+
* the same pass. If some future path anchors an owner outside both, it owes the
|
|
1044
|
+
* same `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
|
|
916
1045
|
*/
|
|
917
1046
|
private async anchorAcceptedSigner(signerKey: string, decision: SeedTrustDecision): Promise<void> {
|
|
918
1047
|
if (!decision.anchorAs || !this.config.trustedOwners) {
|
|
@@ -946,56 +1075,120 @@ export class SeedBootstrapService {
|
|
|
946
1075
|
/**
|
|
947
1076
|
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
948
1077
|
*
|
|
949
|
-
*
|
|
950
|
-
* {@link
|
|
951
|
-
*
|
|
952
|
-
*
|
|
953
|
-
*
|
|
954
|
-
*
|
|
955
|
-
*
|
|
1078
|
+
* `target` is a multiaddr string, dialed as given, or a peer id with its addresses
|
|
1079
|
+
* ({@link SeedDeliveryTarget}). For the second, the connection is formed first and on
|
|
1080
|
+
* its own limits ({@link connectForDelivery}): an open one is reused, otherwise each
|
|
1081
|
+
* address is dialed in turn, so one that never answers cannot use up the time a later
|
|
1082
|
+
* one needed. Its addresses are also merged into the peer store, so identify and later
|
|
1083
|
+
* dials see them. A peer id with no addresses is dialed by id, which reuses an open
|
|
1084
|
+
* connection or tries the peer store's addresses. `options.claimProof` rides beside
|
|
1085
|
+
* the seed in the message when the target is a brand-new node being claimed
|
|
1086
|
+
* (`claim-proof.ts`).
|
|
1087
|
+
*
|
|
1088
|
+
* Sender hardening: the request — opening the stream (with its dial, for the forms
|
|
1089
|
+
* that dial by `dialProtocol`), write, ack read — is bounded by {@link seedDeliverTimeoutMs},
|
|
1090
|
+
* and the ack is capped at {@link MAX_SEED_SIZE}. The target is a NOT-YET-TRUSTED node the
|
|
1091
|
+
* instigator chose to dial during onboarding, so an unbounded read here is strictly more
|
|
1092
|
+
* exposed than the membership-gated receiver paths: without the bound a target that accepts
|
|
1093
|
+
* the stream and never replies parks this call forever, and one that streams arbitrary bytes
|
|
1094
|
+
* as a fake ack exhausts memory. A peer id with addresses can therefore take up to
|
|
1095
|
+
* `dialBudget.totalMs` plus {@link seedDeliverTimeoutMs} (114.5 s at the default declared
|
|
1096
|
+
* link) when none of its addresses answers.
|
|
1097
|
+
*
|
|
1098
|
+
* @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it
|
|
1099
|
+
* formed; nothing was sent. Anything else thrown after the connection formed means the
|
|
1100
|
+
* node was reached and the exchange failed.
|
|
956
1101
|
*/
|
|
957
|
-
async deliverSeed(
|
|
1102
|
+
async deliverSeed(
|
|
1103
|
+
target: SeedDeliveryTarget,
|
|
1104
|
+
seed: ControlNetworkSeed,
|
|
1105
|
+
options?: { claimProof?: string },
|
|
1106
|
+
): Promise<SeedAckMessage> {
|
|
958
1107
|
if (!this.libp2pNode) {
|
|
959
1108
|
throw new Error('Service not initialized');
|
|
960
1109
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
const addr = multiaddr(targetMultiaddr);
|
|
964
|
-
|
|
965
|
-
log('Delivering seed to: %s', targetMultiaddr);
|
|
1110
|
+
const label = typeof target === 'string' ? target : target.peerId;
|
|
1111
|
+
log('Delivering seed to: %s', label);
|
|
966
1112
|
|
|
1113
|
+
const openStream = await this.seedStreamOpener(this.libp2pNode, target);
|
|
967
1114
|
return await withDeadline(
|
|
968
1115
|
this.seedDeliverTimeoutMs,
|
|
969
|
-
`Seed delivery to ${
|
|
970
|
-
(signal) => this.sendSeed(
|
|
1116
|
+
`Seed delivery to ${label}`,
|
|
1117
|
+
(signal) => this.sendSeed(openStream, seed, options?.claimProof, signal),
|
|
971
1118
|
);
|
|
972
1119
|
}
|
|
973
1120
|
|
|
1121
|
+
/**
|
|
1122
|
+
* The part of {@link deliverSeed} that runs before its deadline: how the delivery's stream
|
|
1123
|
+
* will be opened, with any connection it needs already formed.
|
|
1124
|
+
*/
|
|
1125
|
+
private async seedStreamOpener(node: Libp2p, target: SeedDeliveryTarget): Promise<SeedStreamOpener> {
|
|
1126
|
+
if (typeof target === 'string') {
|
|
1127
|
+
const addr = multiaddr(target);
|
|
1128
|
+
return (signal) => node.dialProtocol(addr, SEED_PROTOCOL, { signal });
|
|
1129
|
+
}
|
|
1130
|
+
const peerId = peerIdFromString(target.peerId);
|
|
1131
|
+
const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
|
|
1132
|
+
await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
|
|
1133
|
+
if (addrs.length === 0) {
|
|
1134
|
+
return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
|
|
1135
|
+
}
|
|
1136
|
+
const connection = await this.connectForDelivery(node, peerId, addrs);
|
|
1137
|
+
return (signal) => connection.newStream(SEED_PROTOCOL, { signal });
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* A connection to `peerId` for {@link deliverSeed}: one already open, or one dialed from
|
|
1142
|
+
* `addrs` by {@link dialPeerAddrs} — each address on `dialBudget.perAddressMs`, the whole
|
|
1143
|
+
* peer within `dialBudget.totalMs`, direct addresses before relayed ones and the given
|
|
1144
|
+
* order otherwise.
|
|
1145
|
+
*
|
|
1146
|
+
* @throws {PeerUnreachableError} when no connection formed, with the dial's error as `cause`.
|
|
1147
|
+
*/
|
|
1148
|
+
private async connectForDelivery(node: Libp2p, peerId: PeerId, addrs: readonly Multiaddr[]): Promise<Connection> {
|
|
1149
|
+
const open = openUnlimitedConnection(node, peerId);
|
|
1150
|
+
if (open) {
|
|
1151
|
+
log('Seed delivery to %s reuses an open connection', peerId);
|
|
1152
|
+
return open;
|
|
1153
|
+
}
|
|
1154
|
+
// NOTE: a relayed address can return a limited connection, on which `sendSeed`'s stream
|
|
1155
|
+
// open then fails as a plain error rather than `PeerUnreachableError`, though nothing was
|
|
1156
|
+
// sent. Claim codes carry no relay addresses today; if a delivery target ever may, count a
|
|
1157
|
+
// limited connection as unreachable here.
|
|
1158
|
+
try {
|
|
1159
|
+
return await dialPeerAddrs(node, addrs, this.dialBudget, `Seed delivery dial of ${peerId}`);
|
|
1160
|
+
} catch (error) {
|
|
1161
|
+
throw new PeerUnreachableError(peerId.toString(), error);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
974
1165
|
/**
|
|
975
1166
|
* Open one stream to the target, send the seed frame, half-close, and read the ack.
|
|
976
1167
|
*
|
|
977
|
-
* `signal` is the deadline from {@link deliverSeed}: it goes to `
|
|
978
|
-
*
|
|
979
|
-
* timeout after the stream is open resets it — releasing the otherwise unbounded
|
|
1168
|
+
* `signal` is the deadline from {@link deliverSeed}: it goes to `openStream` so a
|
|
1169
|
+
* timeout while dialing or opening the stream aborts it, and into {@link exchangeFrame}
|
|
1170
|
+
* so a timeout after the stream is open resets it — releasing the otherwise unbounded
|
|
980
1171
|
* ack-read.
|
|
981
1172
|
*
|
|
982
1173
|
* 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
|
|
984
|
-
*
|
|
1174
|
+
* tiny frame over a relay, whereas a seed is up to 1MB, so a relayed address that
|
|
1175
|
+
* forms only a limited connection fails here when the stream is opened. Changing
|
|
1176
|
+
* that is a separate decision.
|
|
985
1177
|
*/
|
|
986
1178
|
private async sendSeed(
|
|
987
|
-
|
|
988
|
-
addr: Multiaddr,
|
|
1179
|
+
openStream: SeedStreamOpener,
|
|
989
1180
|
seed: ControlNetworkSeed,
|
|
1181
|
+
claimProof: string | undefined,
|
|
990
1182
|
signal: AbortSignal,
|
|
991
1183
|
): Promise<SeedAckMessage> {
|
|
992
|
-
const rawStream = await
|
|
1184
|
+
const rawStream = await openStream(signal);
|
|
993
1185
|
|
|
994
1186
|
const message: SeedMessage = {
|
|
995
1187
|
partyId: seed.partyId,
|
|
996
1188
|
peers: seed.peers,
|
|
997
1189
|
signature: seed.signature,
|
|
998
1190
|
signerKey: seed.signerKey,
|
|
1191
|
+
...(claimProof !== undefined ? { claimProof } : {}),
|
|
999
1192
|
};
|
|
1000
1193
|
|
|
1001
1194
|
const ack = await exchangeFrame(
|
|
@@ -1046,17 +1239,17 @@ export class SeedBootstrapService {
|
|
|
1046
1239
|
|
|
1047
1240
|
/**
|
|
1048
1241
|
* Validate a seed's signature.
|
|
1242
|
+
*
|
|
1243
|
+
* @param digestB64 - The seed's {@link seedDigest}, when the caller already computed it
|
|
1244
|
+
* (`verifyAndMergeSeed` shares one digest between this check and the trust context).
|
|
1049
1245
|
*/
|
|
1050
|
-
validateSeedSignature(seed: ControlNetworkSeed): boolean {
|
|
1246
|
+
validateSeedSignature(seed: ControlNetworkSeed, digestB64 = seedDigest(seed)): boolean {
|
|
1051
1247
|
try {
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
const seedJson = canonicalSeedPayload(seed);
|
|
1056
|
-
const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
|
|
1057
|
-
|
|
1248
|
+
// The digest is over the shared canonical payload, so verification is
|
|
1249
|
+
// independent of key order; the payload is the fixed `{ partyId, peers }`
|
|
1250
|
+
// the producer emits.
|
|
1058
1251
|
return verify(
|
|
1059
|
-
|
|
1252
|
+
digestB64,
|
|
1060
1253
|
seed.signature,
|
|
1061
1254
|
seed.signerKey,
|
|
1062
1255
|
'ed25519',
|
|
@@ -1071,52 +1264,16 @@ export class SeedBootstrapService {
|
|
|
1071
1264
|
}
|
|
1072
1265
|
|
|
1073
1266
|
/**
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
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.
|
|
1267
|
+
* The peers a seed carries: the control database's own projection
|
|
1268
|
+
* ({@link ControlDatabase.querySeedPeers}, shared with the cadre invitation redemption
|
|
1269
|
+
* reply), which reads through the revocation filter and flags owners from the replicated
|
|
1270
|
+
* `OwnerKey` table — a dial hint, not a trust decision (the NOTE at `projectSeedPeers`).
|
|
1097
1271
|
*/
|
|
1098
1272
|
private async queryPeers(): Promise<SeedPeer[]> {
|
|
1099
1273
|
if (!this.controlDatabase) {
|
|
1100
1274
|
return [];
|
|
1101
1275
|
}
|
|
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
|
-
});
|
|
1276
|
+
return await this.controlDatabase.querySeedPeers();
|
|
1120
1277
|
}
|
|
1121
1278
|
|
|
1122
1279
|
/**
|
|
@@ -1167,12 +1324,16 @@ export class SeedBootstrapService {
|
|
|
1167
1324
|
this.activeStreams++;
|
|
1168
1325
|
let acked = false;
|
|
1169
1326
|
try {
|
|
1170
|
-
const seed = await this.readSeedFrame(stream);
|
|
1327
|
+
const { seed, claimProof } = await this.readSeedFrame(stream);
|
|
1171
1328
|
this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
|
|
1172
1329
|
|
|
1173
|
-
const merged = await this.verifyAndMergeSeed(seed);
|
|
1330
|
+
const merged = await this.verifyAndMergeSeed(seed, { claimProof, remotePeerId });
|
|
1174
1331
|
acked = true;
|
|
1175
|
-
await replyAndClose(
|
|
1332
|
+
await replyAndClose(
|
|
1333
|
+
stream,
|
|
1334
|
+
{ accepted: merged.success, reason: merged.error, code: merged.code } satisfies SeedAckMessage,
|
|
1335
|
+
'Seed',
|
|
1336
|
+
);
|
|
1176
1337
|
|
|
1177
1338
|
if (merged.success) {
|
|
1178
1339
|
await this.dialSeedOwners(seed);
|
|
@@ -1193,8 +1354,12 @@ export class SeedBootstrapService {
|
|
|
1193
1354
|
}
|
|
1194
1355
|
}
|
|
1195
1356
|
|
|
1196
|
-
/**
|
|
1197
|
-
|
|
1357
|
+
/**
|
|
1358
|
+
* Read the inbound seed frame to EOF (bounded and size-capped) and decode it. The
|
|
1359
|
+
* claim proof comes back beside the seed: it is not part of what the owner signed, so
|
|
1360
|
+
* `ControlNetworkSeed` does not carry it.
|
|
1361
|
+
*/
|
|
1362
|
+
private async readSeedFrame(stream: ControlStream): Promise<{ seed: ControlNetworkSeed; claimProof?: string }> {
|
|
1198
1363
|
const data = await readStreamToEnd(stream, {
|
|
1199
1364
|
maxBytes: MAX_SEED_SIZE,
|
|
1200
1365
|
timeoutMs: this.seedReadTimeoutMs,
|
|
@@ -1202,10 +1367,13 @@ export class SeedBootstrapService {
|
|
|
1202
1367
|
});
|
|
1203
1368
|
const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data))) as SeedMessage;
|
|
1204
1369
|
return {
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1370
|
+
seed: {
|
|
1371
|
+
partyId: message.partyId,
|
|
1372
|
+
peers: message.peers,
|
|
1373
|
+
signature: message.signature,
|
|
1374
|
+
signerKey: message.signerKey,
|
|
1375
|
+
},
|
|
1376
|
+
claimProof: message.claimProof,
|
|
1209
1377
|
};
|
|
1210
1378
|
}
|
|
1211
1379
|
|
|
@@ -1263,104 +1431,6 @@ export class SeedBootstrapService {
|
|
|
1263
1431
|
return { seed, encodedSeed };
|
|
1264
1432
|
}
|
|
1265
1433
|
|
|
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
1434
|
/**
|
|
1365
1435
|
* Add a phone to the cadre with relay support.
|
|
1366
1436
|
*
|
|
@@ -1398,51 +1468,4 @@ export class SeedBootstrapService {
|
|
|
1398
1468
|
|
|
1399
1469
|
return { seed, encodedSeed };
|
|
1400
1470
|
}
|
|
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
1471
|
}
|
|
1448
|
-
|