@serfab/cadre-core 0.8.1 → 0.10.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/README.md +6 -4
- package/dist/bootstrap-peer-store-file.d.ts +13 -0
- package/dist/bootstrap-peer-store-file.js +42 -0
- package/dist/bootstrap-peer-store-file.js.map +1 -0
- package/dist/bootstrap-peer-store.d.ts +73 -0
- package/dist/bootstrap-peer-store.js +128 -0
- package/dist/bootstrap-peer-store.js.map +1 -0
- package/dist/cadre-node.d.ts +1006 -101
- package/dist/cadre-node.js +1977 -283
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +93 -0
- package/dist/control-authorization.js +57 -0
- package/dist/control-authorization.js.map +1 -0
- package/dist/control-cohort.d.ts +15 -15
- package/dist/control-cohort.js +14 -14
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +1033 -76
- package/dist/control-database.js +1520 -153
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +91 -12
- package/dist/control-formation-recorder.js +131 -16
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +774 -186
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +38 -4
- package/dist/control-stream.js +70 -4
- package/dist/control-stream.js.map +1 -1
- package/dist/control-write-retry.d.ts +151 -0
- package/dist/control-write-retry.js +411 -0
- package/dist/control-write-retry.js.map +1 -0
- package/dist/delegate-admission.d.ts +134 -0
- package/dist/delegate-admission.js +244 -0
- package/dist/delegate-admission.js.map +1 -0
- package/dist/device-token.d.ts +12 -12
- package/dist/device-token.js +15 -14
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +68 -0
- package/dist/ed25519-key.js +106 -0
- package/dist/ed25519-key.js.map +1 -0
- package/dist/file-durable-slot.d.ts +20 -0
- package/dist/file-durable-slot.js +53 -0
- package/dist/file-durable-slot.js.map +1 -0
- package/dist/formation-approval.d.ts +157 -0
- package/dist/formation-approval.js +375 -0
- package/dist/formation-approval.js.map +1 -0
- package/dist/fs-atomic.d.ts +38 -0
- package/dist/fs-atomic.js +97 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/identity-key.d.ts +49 -0
- package/dist/identity-key.js +81 -0
- package/dist/identity-key.js.map +1 -0
- package/dist/index.d.ts +16 -6
- package/dist/index.js +44 -10
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +2 -24
- package/dist/key-store-file.js +10 -77
- package/dist/key-store-file.js.map +1 -1
- package/dist/key-store.d.ts +1 -1
- package/dist/key-store.js +1 -1
- package/dist/membership-connection-gater.d.ts +146 -0
- package/dist/membership-connection-gater.js +180 -0
- package/dist/membership-connection-gater.js.map +1 -0
- package/dist/node-local-snapshot.d.ts +109 -0
- package/dist/node-local-snapshot.js +182 -0
- package/dist/node-local-snapshot.js.map +1 -0
- package/dist/peer-authorization.d.ts +150 -11
- package/dist/peer-authorization.js +170 -13
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-record.d.ts +24 -11
- package/dist/peer-record.js +29 -13
- package/dist/peer-record.js.map +1 -1
- package/dist/push-node.d.ts +38 -0
- package/dist/push-node.js +48 -0
- package/dist/push-node.js.map +1 -0
- package/dist/push-notifier-apns.d.ts +10 -2
- package/dist/push-notifier-apns.js +14 -10
- package/dist/push-notifier-apns.js.map +1 -1
- package/dist/push-notifier-fcm.d.ts +7 -1
- package/dist/push-notifier-fcm.js +9 -10
- package/dist/push-notifier-fcm.js.map +1 -1
- package/dist/push-notifier.d.ts +16 -35
- package/dist/push-notifier.js +16 -44
- package/dist/push-notifier.js.map +1 -1
- package/dist/relay-addrs.d.ts +45 -0
- package/dist/relay-addrs.js +103 -0
- package/dist/relay-addrs.js.map +1 -0
- package/dist/relay-reservation.d.ts +266 -0
- package/dist/relay-reservation.js +613 -0
- package/dist/relay-reservation.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +263 -79
- package/dist/seed-bootstrap.js +413 -211
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +50 -24
- package/dist/seed-trust-policy.js +28 -19
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +42 -13
- package/dist/strand-addr-protocol.js +58 -52
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.d.ts +109 -0
- package/dist/strand-backfill.js +279 -0
- package/dist/strand-backfill.js.map +1 -0
- package/dist/strand-database.d.ts +5 -5
- package/dist/strand-database.js +4 -4
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +38 -9
- package/dist/strand-formation-manager.js +120 -17
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +104 -5
- package/dist/strand-formation-protocol.js +233 -5
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +40 -2
- package/dist/strand-instance-manager.js +93 -15
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +6 -6
- package/dist/strand-member-key.js +6 -6
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.d.ts +12 -12
- package/dist/strand-member-registry.js +22 -14
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +532 -138
- package/dist/strand-membership-writer.js +800 -179
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +99 -33
- package/dist/strand-solicitation.js +100 -52
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-transport-key.d.ts +40 -0
- package/dist/strand-transport-key.js +56 -0
- package/dist/strand-transport-key.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +9 -7
- package/dist/strand-wake-protocol.js +22 -46
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.d.ts +17 -2
- package/dist/strand-watcher.js +47 -3
- package/dist/strand-watcher.js.map +1 -1
- package/dist/trusted-owner-store-file.d.ts +12 -0
- package/dist/trusted-owner-store-file.js +39 -0
- package/dist/trusted-owner-store-file.js.map +1 -0
- package/dist/trusted-owner-store.d.ts +64 -0
- package/dist/trusted-owner-store.js +126 -0
- package/dist/trusted-owner-store.js.map +1 -0
- package/dist/types.d.ts +325 -50
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/package.json +23 -8
- package/src/bootstrap-peer-store-file.ts +44 -0
- package/src/bootstrap-peer-store.ts +172 -0
- package/src/cadre-node.ts +2129 -295
- package/src/control-authorization.ts +114 -0
- package/src/control-cohort.ts +17 -17
- package/src/control-database.ts +1766 -191
- package/src/control-formation-recorder.ts +174 -21
- package/src/control-schema.ts +786 -198
- package/src/control-stream.ts +81 -4
- package/src/control-write-retry.ts +473 -0
- package/src/delegate-admission.ts +302 -0
- package/src/device-token.ts +19 -14
- package/src/ed25519-key.ts +134 -0
- package/src/file-durable-slot.ts +61 -0
- package/src/formation-approval.ts +604 -0
- package/src/fs-atomic.ts +107 -0
- package/src/identity-key.ts +109 -0
- package/src/index.ts +157 -24
- package/src/key-store-file.ts +10 -81
- package/src/key-store.ts +1 -1
- package/src/membership-connection-gater.ts +204 -0
- package/src/node-local-snapshot.ts +260 -0
- package/src/peer-authorization.ts +219 -13
- package/src/peer-record.ts +31 -13
- package/src/push-node.ts +64 -0
- package/src/push-notifier-apns.ts +16 -12
- package/src/push-notifier-fcm.ts +9 -10
- package/src/push-notifier.ts +16 -57
- package/src/relay-addrs.ts +111 -0
- package/src/relay-reservation.ts +823 -0
- package/src/seed-bootstrap.ts +490 -226
- package/src/seed-trust-policy.ts +65 -32
- package/src/strand-addr-protocol.ts +84 -53
- package/src/strand-backfill.ts +366 -0
- package/src/strand-database.ts +6 -6
- package/src/strand-formation-manager.ts +145 -19
- package/src/strand-formation-protocol.ts +310 -9
- package/src/strand-instance-manager.ts +113 -16
- package/src/strand-member-key.ts +7 -7
- package/src/strand-member-registry.ts +28 -23
- package/src/strand-membership-writer.ts +1430 -640
- package/src/strand-solicitation.ts +155 -90
- package/src/strand-transport-key.ts +58 -0
- package/src/strand-wake-protocol.ts +29 -46
- package/src/strand-watcher.ts +66 -3
- package/src/trusted-owner-store-file.ts +41 -0
- package/src/trusted-owner-store.ts +171 -0
- package/src/types.ts +342 -51
- package/dist/authority-key.d.ts +0 -41
- package/dist/authority-key.js +0 -52
- package/dist/authority-key.js.map +0 -1
- package/src/authority-key.ts +0 -68
package/src/seed-bootstrap.ts
CHANGED
|
@@ -2,9 +2,9 @@ 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
4
|
import type { Libp2p, Connection } from '@libp2p/interface';
|
|
5
|
-
import { multiaddr } from '@multiformats/multiaddr';
|
|
5
|
+
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
6
6
|
import { peerIdFromString } from '@libp2p/peer-id';
|
|
7
|
-
import { type ControlStream, writeFrame, readStreamToEnd } from './control-stream.js';
|
|
7
|
+
import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
|
|
8
8
|
import type {
|
|
9
9
|
ControlNetworkSeed,
|
|
10
10
|
SeedPeer,
|
|
@@ -18,15 +18,19 @@ import type {
|
|
|
18
18
|
InviteResult,
|
|
19
19
|
CadreInvite,
|
|
20
20
|
PeerAddressRecord,
|
|
21
|
-
DeviceTokenRecord
|
|
21
|
+
DeviceTokenRecord,
|
|
22
|
+
RevocationRow
|
|
22
23
|
} from './types.js';
|
|
23
24
|
import type { ControlDatabase } from './control-database.js';
|
|
25
|
+
import { generateStampId } from './control-database.js';
|
|
24
26
|
import { canonicalJson } from './canonical-json.js';
|
|
25
|
-
import {
|
|
27
|
+
import { deviceTokenAddDigest } from './peer-authorization.js';
|
|
26
28
|
import {
|
|
27
29
|
type SeedTrustPolicy,
|
|
28
|
-
|
|
30
|
+
type SeedTrustDecision,
|
|
31
|
+
anchoredTrustPolicy,
|
|
29
32
|
} from './seed-trust-policy.js';
|
|
33
|
+
import type { TrustedOwnerStore } from './trusted-owner-store.js';
|
|
30
34
|
|
|
31
35
|
const log = debug('sereus:cadre:seed-bootstrap');
|
|
32
36
|
|
|
@@ -42,6 +46,9 @@ const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
|
|
|
42
46
|
/** Default cap on concurrent inbound seed streams a single peer can pin open. */
|
|
43
47
|
const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
|
|
44
48
|
|
|
49
|
+
/** Default time the sender waits for a seed delivery (dial + ack read) before aborting (ms). */
|
|
50
|
+
const DEFAULT_SEED_DELIVER_TIMEOUT_MS = 10_000;
|
|
51
|
+
|
|
45
52
|
/**
|
|
46
53
|
* Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
|
|
47
54
|
*
|
|
@@ -73,10 +80,10 @@ export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED
|
|
|
73
80
|
*
|
|
74
81
|
* An Ed25519 PeerId is an identity multihash of the public key, so
|
|
75
82
|
* `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
|
|
76
|
-
* base64url form matches the `
|
|
77
|
-
* `
|
|
83
|
+
* base64url form matches the `OwnerKey.Key` representation (and
|
|
84
|
+
* `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
|
|
78
85
|
* id, a missing embedded key, or any parse failure — callers treat null as
|
|
79
|
-
* "not an
|
|
86
|
+
* "not an owner" rather than throwing.
|
|
80
87
|
*/
|
|
81
88
|
export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
|
|
82
89
|
try {
|
|
@@ -105,16 +112,24 @@ export function canonicalSeedPayload(
|
|
|
105
112
|
return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
|
|
106
113
|
}
|
|
107
114
|
|
|
115
|
+
/**
|
|
116
|
+
* An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
|
|
117
|
+
* so the dial counters read zero rather than being absent.
|
|
118
|
+
*/
|
|
119
|
+
function seedRejected(error: string): ApplySeedResult {
|
|
120
|
+
return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
|
|
121
|
+
}
|
|
122
|
+
|
|
108
123
|
/**
|
|
109
124
|
* Configuration for the SeedBootstrapService
|
|
110
125
|
*/
|
|
111
126
|
export interface SeedBootstrapConfig {
|
|
112
127
|
/** Party ID for this cadre */
|
|
113
128
|
partyId: string;
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
|
|
129
|
+
/** Owner private key for signing seeds and peer authorizations (base64url) */
|
|
130
|
+
ownerPrivateKey?: string;
|
|
131
|
+
/** Owner public key (base64url) - derived from private key if not provided */
|
|
132
|
+
ownerPublicKey?: string;
|
|
118
133
|
/**
|
|
119
134
|
* Optional async resolver returning the multiaddrs to embed in invites.
|
|
120
135
|
* When unset, `libp2pNode.getMultiaddrs()` is used. Hosts behind NAT supply
|
|
@@ -124,9 +139,9 @@ export interface SeedBootstrapConfig {
|
|
|
124
139
|
inviteAddressResolver?: () => Promise<string[]>;
|
|
125
140
|
/**
|
|
126
141
|
* Trust anchor for incoming seeds. Decides whether a signature-verified
|
|
127
|
-
* `signerKey` should be trusted, against the receiver's
|
|
128
|
-
* keys (NOT the seed body). Defaults to `
|
|
129
|
-
* rejects any signer not already in
|
|
142
|
+
* `signerKey` should be trusted, against the receiver's anchored owner
|
|
143
|
+
* keys (NOT the seed body). Defaults to `anchoredTrustPolicy()`, which
|
|
144
|
+
* rejects any signer not already in {@link trustedOwners}. An enrollment
|
|
130
145
|
* caller can pass a per-seed override to `applySeed` instead.
|
|
131
146
|
*
|
|
132
147
|
* A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
|
|
@@ -134,6 +149,19 @@ export interface SeedBootstrapConfig {
|
|
|
134
149
|
* since a network-delivered seed has no per-call override.
|
|
135
150
|
*/
|
|
136
151
|
trustPolicy?: SeedTrustPolicy;
|
|
152
|
+
/**
|
|
153
|
+
* The node-local, NON-replicated trusted-owner anchor. Supplies
|
|
154
|
+
* `SeedTrustContext.knownOwnerKeys` for every {@link applySeed}, and receives
|
|
155
|
+
* a key accepted via a pin/TOFU (see `SeedTrustDecision.anchorAs`).
|
|
156
|
+
*
|
|
157
|
+
* Deliberately NOT `ControlDatabase.getOwnerKeys()`: the replicated
|
|
158
|
+
* `OwnerKey` table is pollutable — any connecting node can genesis-insert its
|
|
159
|
+
* own key and let it replicate — so a seed signed by a stranger's self-issued
|
|
160
|
+
* owner key would pass a table-anchored check. A `CadreNode` passes its
|
|
161
|
+
* `getTrustedOwnerStore()` here. Unset (e.g. a directly-constructed service in
|
|
162
|
+
* a test) means an EMPTY anchor: only a pinned/TOFU policy can accept a seed.
|
|
163
|
+
*/
|
|
164
|
+
trustedOwners?: TrustedOwnerStore;
|
|
137
165
|
/**
|
|
138
166
|
* Time the inbound seed handler waits for the seed frame before aborting the
|
|
139
167
|
* read (ms). Defaults to {@link DEFAULT_SEED_READ_TIMEOUT_MS}. Bounds a
|
|
@@ -146,6 +174,15 @@ export interface SeedBootstrapConfig {
|
|
|
146
174
|
* returned without applying any seed.
|
|
147
175
|
*/
|
|
148
176
|
maxConcurrentSeeds?: number;
|
|
177
|
+
/**
|
|
178
|
+
* Time {@link SeedBootstrapService.deliverSeed} waits for the whole exchange —
|
|
179
|
+
* dial, write, ack read — before aborting (ms). Defaults to
|
|
180
|
+
* {@link DEFAULT_SEED_DELIVER_TIMEOUT_MS}. Bounds the SENDER against a seed
|
|
181
|
+
* target that accepts the stream and then never replies; the target is a
|
|
182
|
+
* not-yet-trusted node during onboarding, so this is the more exposed
|
|
183
|
+
* direction than the receiver knobs above.
|
|
184
|
+
*/
|
|
185
|
+
seedDeliverTimeoutMs?: number;
|
|
149
186
|
}
|
|
150
187
|
|
|
151
188
|
/**
|
|
@@ -154,8 +191,15 @@ export interface SeedBootstrapConfig {
|
|
|
154
191
|
export interface SeedEventCallbacks {
|
|
155
192
|
/** Called when a seed is received via the protocol */
|
|
156
193
|
onSeedReceived?: (partyId: string, peerId: string) => void;
|
|
157
|
-
/**
|
|
158
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Called when a seed is successfully applied.
|
|
196
|
+
*
|
|
197
|
+
* `seed` is the applied seed itself: the inbound protocol handler applies it
|
|
198
|
+
* INSIDE the service, so this callback is the only seam through which a
|
|
199
|
+
* `CadreNode` sees a wire-delivered seed's contents — which it needs to
|
|
200
|
+
* retain the owner-flagged peers as cold-start bootstrap dial targets.
|
|
201
|
+
*/
|
|
202
|
+
onSeedApplied?: (partyId: string, peersAdded: number, seed: ControlNetworkSeed) => void;
|
|
159
203
|
/** Called when seed application fails */
|
|
160
204
|
onSeedError?: (partyId: string, error: string) => void;
|
|
161
205
|
}
|
|
@@ -171,30 +215,32 @@ export class SeedBootstrapService {
|
|
|
171
215
|
private readonly config: SeedBootstrapConfig;
|
|
172
216
|
private libp2pNode: Libp2p | null = null;
|
|
173
217
|
private controlDatabase: ControlDatabase | null = null;
|
|
174
|
-
private readonly
|
|
218
|
+
private readonly ownerPublicKey: string | null;
|
|
175
219
|
private readonly trustPolicy: SeedTrustPolicy;
|
|
176
220
|
private readonly seedReadTimeoutMs: number;
|
|
177
221
|
private readonly maxConcurrentSeeds: number;
|
|
222
|
+
private readonly seedDeliverTimeoutMs: number;
|
|
178
223
|
/** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
|
|
179
224
|
private activeStreams = 0;
|
|
180
225
|
private eventCallbacks: SeedEventCallbacks = {};
|
|
181
226
|
|
|
182
227
|
constructor(config: SeedBootstrapConfig) {
|
|
183
228
|
this.config = config;
|
|
184
|
-
this.trustPolicy = config.trustPolicy ??
|
|
229
|
+
this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
|
|
185
230
|
this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
|
|
186
231
|
this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
|
|
232
|
+
this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? DEFAULT_SEED_DELIVER_TIMEOUT_MS;
|
|
187
233
|
|
|
188
234
|
// Derive public key from private key if not provided
|
|
189
|
-
if (config.
|
|
190
|
-
this.
|
|
191
|
-
config.
|
|
235
|
+
if (config.ownerPrivateKey && !config.ownerPublicKey) {
|
|
236
|
+
this.ownerPublicKey = getPublicKey(
|
|
237
|
+
config.ownerPrivateKey,
|
|
192
238
|
'ed25519',
|
|
193
239
|
'base64url',
|
|
194
240
|
'base64url'
|
|
195
241
|
) as string;
|
|
196
242
|
} else {
|
|
197
|
-
this.
|
|
243
|
+
this.ownerPublicKey = config.ownerPublicKey ?? null;
|
|
198
244
|
}
|
|
199
245
|
|
|
200
246
|
log('SeedBootstrapService created for party: %s', config.partyId);
|
|
@@ -209,15 +255,15 @@ export class SeedBootstrapService {
|
|
|
209
255
|
}
|
|
210
256
|
|
|
211
257
|
/**
|
|
212
|
-
* Whether this service holds an
|
|
213
|
-
*
|
|
258
|
+
* Whether this service holds an owner private key, i.e. can produce the
|
|
259
|
+
* owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
|
|
214
260
|
* and re-authorizations. A seed-listener-only service (`enableSeedListener`,
|
|
215
|
-
* no
|
|
216
|
-
* or re-issue
|
|
217
|
-
* drain to skip
|
|
261
|
+
* no owner key) returns false: it can receive/apply seeds but cannot author
|
|
262
|
+
* or re-issue owner writes. Used by the write-while-alone re-replication
|
|
263
|
+
* drain to skip owner work on a non-owner node.
|
|
218
264
|
*/
|
|
219
265
|
canAuthorize(): boolean {
|
|
220
|
-
return !!this.config.
|
|
266
|
+
return !!this.config.ownerPrivateKey;
|
|
221
267
|
}
|
|
222
268
|
|
|
223
269
|
/**
|
|
@@ -250,14 +296,14 @@ export class SeedBootstrapService {
|
|
|
250
296
|
|
|
251
297
|
/**
|
|
252
298
|
* Authorize a new peer to join the cadre.
|
|
253
|
-
* Signs
|
|
299
|
+
* Signs a membership voucher with the owner key and inserts into CadrePeer table.
|
|
254
300
|
*
|
|
255
|
-
* The
|
|
301
|
+
* The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
|
|
256
302
|
* caller-supplied key, the binding is enforced by construction — `PublicKey` is
|
|
257
303
|
* DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
|
|
258
304
|
* `PublicKey`, and such a row can never be self-updated (it has no key to
|
|
259
305
|
* verify against), which is correct. The row is inserted with a fresh
|
|
260
|
-
* `UpdatedAt` but no self-signature (`Sig` null) — the
|
|
306
|
+
* `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
|
|
261
307
|
* the peer's self-signature, so the peer must self-publish (see
|
|
262
308
|
* {@link CadreNode.registerSelf}) before the row resolves.
|
|
263
309
|
*/
|
|
@@ -277,15 +323,20 @@ export class SeedBootstrapService {
|
|
|
277
323
|
}
|
|
278
324
|
|
|
279
325
|
/**
|
|
280
|
-
*
|
|
326
|
+
* Owner-signed INSERT of this node's OWN self-signed address record.
|
|
281
327
|
*
|
|
282
328
|
* Used by {@link CadreNode.registerSelf} when the node is not yet a member and
|
|
283
|
-
* is its own
|
|
329
|
+
* is its own owner (it holds the owner key): the row is owner-signed
|
|
284
330
|
* (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
|
|
285
331
|
* immediately without a follow-up self-update.
|
|
332
|
+
*
|
|
333
|
+
* @returns `true` when this call seated the row, `false` when a concurrent writer
|
|
334
|
+
* (e.g. an {@link authorizePeer} of this node's own id) had already seated it —
|
|
335
|
+
* in which case the row in the database is NOT this record and carries whatever
|
|
336
|
+
* `Sig` that writer had, so the caller must self-update to publish its signature.
|
|
286
337
|
*/
|
|
287
|
-
async insertSelfPeerRecord(record: PeerAddressRecord): Promise<
|
|
288
|
-
await this.insertCadrePeerRow({
|
|
338
|
+
async insertSelfPeerRecord(record: PeerAddressRecord): Promise<boolean> {
|
|
339
|
+
return await this.insertCadrePeerRow({
|
|
289
340
|
peerId: record.peerId,
|
|
290
341
|
publicKey: record.publicKey,
|
|
291
342
|
multiaddr: record.addrs.join(','),
|
|
@@ -295,11 +346,20 @@ export class SeedBootstrapService {
|
|
|
295
346
|
}
|
|
296
347
|
|
|
297
348
|
/**
|
|
298
|
-
* Shared
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
349
|
+
* Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
|
|
350
|
+
* precondition; the stamp mint, voucher signature, write lock, existence check and
|
|
351
|
+
* membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
|
|
352
|
+
* the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
|
|
353
|
+
* method: the write is what admits the peer's traffic.
|
|
354
|
+
*
|
|
355
|
+
* The DB check precedes the owner-key check here, matching the order this method has
|
|
356
|
+
* always surfaced them (the owner-key error used to come out of the signer, after the
|
|
357
|
+
* DB check).
|
|
358
|
+
*
|
|
359
|
+
* @returns `true` when this call performed the INSERT, `false` when the row was
|
|
360
|
+
* already seated by a concurrent writer. The loser needs to know: an authorize seats
|
|
361
|
+
* a row with a null `Sig`, so a self-publish that lost the race must fall through to
|
|
362
|
+
* a self-update or its record never lands.
|
|
303
363
|
*/
|
|
304
364
|
private async insertCadrePeerRow(row: {
|
|
305
365
|
peerId: string;
|
|
@@ -307,78 +367,122 @@ export class SeedBootstrapService {
|
|
|
307
367
|
multiaddr: string;
|
|
308
368
|
updatedAt: number;
|
|
309
369
|
sig: string | null;
|
|
310
|
-
}): Promise<
|
|
311
|
-
const signature = this.signPeerAuthorization(row.peerId);
|
|
370
|
+
}): Promise<boolean> {
|
|
312
371
|
if (!this.controlDatabase) {
|
|
313
372
|
throw new Error('Control database not initialized');
|
|
314
373
|
}
|
|
315
|
-
const
|
|
316
|
-
await
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
values (?, ?, ?, ?, ?)
|
|
320
|
-
`, [this.authorityPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig]);
|
|
374
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
375
|
+
return await this.controlDatabase.insertCadrePeer(
|
|
376
|
+
row, ownerKey, message => this.signMessageBytes(message),
|
|
377
|
+
);
|
|
321
378
|
}
|
|
322
379
|
|
|
323
380
|
/**
|
|
324
|
-
*
|
|
381
|
+
* Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
|
|
325
382
|
*
|
|
326
383
|
* Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
|
|
327
|
-
* row is
|
|
328
|
-
*
|
|
329
|
-
* own self-`Sig` over the token payload. The
|
|
330
|
-
*
|
|
384
|
+
* row is owner-signed (satisfying `DeviceToken.AuthorizedInsert` via the
|
|
385
|
+
* 'add'-tagged digest, {@link deviceTokenAddDigest}) AND carries the peer's
|
|
386
|
+
* own self-`Sig` over the token payload. The owner signature covers the WHOLE row
|
|
387
|
+
* (every column, ending in a freshly minted single-use `StampId`) — but covering the
|
|
388
|
+
* token contents is not the same as vouching them: the peer's `Sig` (verified at
|
|
331
389
|
* resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
|
|
332
390
|
* resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
|
|
333
|
-
* when the node is its own
|
|
391
|
+
* when the node is its own owner.
|
|
392
|
+
*
|
|
393
|
+
* The stamp is per-INSERT, so a re-register after a clear mints a fresh one and a
|
|
394
|
+
* fresh signature — unaffected by the cleared row's retired stamp
|
|
395
|
+
* (`DeviceToken.NotRevoked`).
|
|
334
396
|
*/
|
|
335
397
|
async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
|
|
336
|
-
const
|
|
398
|
+
const stampId = generateStampId(record.peerId);
|
|
399
|
+
const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
|
|
337
400
|
if (!this.controlDatabase) {
|
|
338
401
|
throw new Error('Control database not initialized');
|
|
339
402
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
log('Device token inserted (authority-signed): %s', record.peerId);
|
|
403
|
+
await this.controlDatabase.execWrite(`
|
|
404
|
+
insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
|
|
405
|
+
with context OwnerKey = ?, Signature = ?
|
|
406
|
+
values (?, ?, ?, ?, ?, ?)
|
|
407
|
+
`, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId]);
|
|
408
|
+
log('Device token inserted (owner-signed): %s', record.peerId);
|
|
347
409
|
}
|
|
348
410
|
|
|
349
411
|
/**
|
|
350
|
-
*
|
|
351
|
-
* invalidation). The `DeviceToken.
|
|
352
|
-
*
|
|
353
|
-
*
|
|
412
|
+
* Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
|
|
413
|
+
* invalidation). The `DeviceToken.AuthorizedDelete` constraint validates an owner
|
|
414
|
+
* signature over the 'remove'-tagged digest bound to the STORED row's
|
|
415
|
+
* (PeerId, StampId) — deliberately distinct from the insert digest, so a captured
|
|
416
|
+
* insert approval can never be replayed to clear a token. Like {@link removePeer}
|
|
417
|
+
* for `CadrePeer`, clearing a token requires the owner key.
|
|
418
|
+
*
|
|
419
|
+
* The delete and the `Revocation` tombstone retiring the row's `StampId` commit in
|
|
420
|
+
* ONE transaction — `DeviceToken.RevocationRecorded` refuses a bare delete, and
|
|
421
|
+
* without the tombstone the stamp would free up and the never-expiring insert
|
|
422
|
+
* approval (which the cleared device holds a copy of) would re-seat the token. The
|
|
423
|
+
* tombstone carries its OWN owner signature: retiring a stamp permanently forecloses
|
|
424
|
+
* that row, so it is an owner action in its own right.
|
|
425
|
+
*
|
|
426
|
+
* Both digests, the stamp read, and the transaction come from
|
|
427
|
+
* {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
|
|
428
|
+
* `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
|
|
429
|
+
* owner-key precondition; a no-op on an already-absent row is the shared body's
|
|
430
|
+
* behavior, and unlike {@link removePeer} nothing here rides on it.
|
|
354
431
|
*/
|
|
355
432
|
async deleteDeviceToken(peerId: string): Promise<void> {
|
|
356
|
-
|
|
433
|
+
// Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
|
|
434
|
+
// owner-key error rather than a silent no-op on an absent row.
|
|
435
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
357
436
|
if (!this.controlDatabase) {
|
|
358
437
|
throw new Error('Control database not initialized');
|
|
359
438
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
with context AuthorityKey = ?, Signature = ?
|
|
364
|
-
where PeerId = ?
|
|
365
|
-
`, [this.authorityPublicKey, signature, peerId]);
|
|
366
|
-
log('Device token removed (authority-signed): %s', peerId);
|
|
439
|
+
await this.controlDatabase.deleteDeviceToken(
|
|
440
|
+
peerId, ownerKey, message => this.signMessageBytes(message),
|
|
441
|
+
);
|
|
367
442
|
}
|
|
368
443
|
|
|
369
444
|
/**
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
* {@link
|
|
373
|
-
*
|
|
445
|
+
* Return the configured owner private key, or throw if none is set. The single
|
|
446
|
+
* precondition gate for every owner-signed write. {@link removePeer} /
|
|
447
|
+
* {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
|
|
448
|
+
* they call this up front — otherwise a keyless service would either surface the
|
|
449
|
+
* wrong "Control database not initialized" error or, worse, silently no-op when the
|
|
450
|
+
* target row is absent (the early `stampId === null` return) instead of rejecting.
|
|
374
451
|
*/
|
|
375
|
-
private
|
|
376
|
-
if (!this.config.
|
|
377
|
-
throw new Error('
|
|
452
|
+
private requireOwnerPrivateKey(): string {
|
|
453
|
+
if (!this.config.ownerPrivateKey) {
|
|
454
|
+
throw new Error('Owner private key required to authorize peers');
|
|
378
455
|
}
|
|
456
|
+
return this.config.ownerPrivateKey;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The owner PUBLIC key that rides in every owner-signed write's context, non-null.
|
|
461
|
+
*
|
|
462
|
+
* The field is nullable because a read-only service carries no owner key at all, but the
|
|
463
|
+
* constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
|
|
464
|
+
* {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
|
|
465
|
+
* unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
|
|
466
|
+
* their first line and get the owner-key precondition for free.
|
|
467
|
+
*/
|
|
468
|
+
private requireOwnerPublicKey(): string {
|
|
469
|
+
this.requireOwnerPrivateKey();
|
|
470
|
+
if (!this.ownerPublicKey) {
|
|
471
|
+
throw new Error('Owner public key required to authorize peers');
|
|
472
|
+
}
|
|
473
|
+
return this.ownerPublicKey;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Sign a base64url digest with the owner key (ed25519). The single place the
|
|
478
|
+
* owner private key is applied; callers pass the canonical domain-tagged digest for
|
|
479
|
+
* the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
|
|
480
|
+
* {@link signMessageBytes}. Throws if no owner key is set.
|
|
481
|
+
*/
|
|
482
|
+
private signDigest(digestB64url: string): string {
|
|
379
483
|
return sign(
|
|
380
|
-
|
|
381
|
-
this.
|
|
484
|
+
digestB64url,
|
|
485
|
+
this.requireOwnerPrivateKey(),
|
|
382
486
|
'ed25519',
|
|
383
487
|
'base64url',
|
|
384
488
|
'base64url',
|
|
@@ -387,78 +491,140 @@ export class SeedBootstrapService {
|
|
|
387
491
|
}
|
|
388
492
|
|
|
389
493
|
/**
|
|
390
|
-
*
|
|
494
|
+
* Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
|
|
495
|
+
* takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
|
|
496
|
+
* same signed bytes (`sign` decodes its base64url input), so a signature minted here
|
|
497
|
+
* satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
|
|
498
|
+
* `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
|
|
499
|
+
*/
|
|
500
|
+
private signMessageBytes(message: Uint8Array): string {
|
|
501
|
+
return this.signDigest(uint8ArrayToString(message, 'base64url'));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Remove a peer from the cadre by owner signature.
|
|
391
506
|
*
|
|
392
|
-
* The
|
|
393
|
-
* signature over
|
|
394
|
-
* digest
|
|
507
|
+
* The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
|
|
508
|
+
* signature over the DISTINCT 'remove'-tagged digest
|
|
509
|
+
* `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
|
|
510
|
+
* key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
|
|
511
|
+
* never be replayed to delete.
|
|
512
|
+
*
|
|
513
|
+
* The delete and the `Revocation` tombstone retiring the row's `StampId` commit in ONE
|
|
514
|
+
* transaction — `CadrePeer.RevocationRecorded` refuses a bare delete, and without the
|
|
515
|
+
* tombstone the stamp would free up and the original admission approval (which never
|
|
516
|
+
* expires, and which the removed peer holds a copy of) would re-seat the row.
|
|
517
|
+
*
|
|
518
|
+
* The tombstone is separately owner-signed (satisfying `Revocation.Authorized`):
|
|
519
|
+
* retiring a stamp evicts that peer party-wide and permanently forecloses re-admitting
|
|
520
|
+
* the row, so it is an owner action in its own right, not a side effect the delete's
|
|
521
|
+
* signature covers.
|
|
522
|
+
*
|
|
523
|
+
* Both digests, the stamp read, the transaction, and the post-commit membership notify
|
|
524
|
+
* come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
|
|
525
|
+
* `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
|
|
526
|
+
* owner-key precondition and the absent-row gate the notify depends on (below).
|
|
395
527
|
*/
|
|
396
528
|
async removePeer(peerId: string): Promise<void> {
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
|
|
529
|
+
// Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
|
|
530
|
+
// remove digest, and this precedence (owner key, then control DB) is what the
|
|
531
|
+
// unit contract asserts.
|
|
532
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
400
533
|
if (!this.controlDatabase) {
|
|
401
534
|
throw new Error('Control database not initialized');
|
|
402
535
|
}
|
|
536
|
+
// This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
|
|
537
|
+
// internally: deleteCadrePeer's membership notify fires whenever its body resolves,
|
|
538
|
+
// with no idea whether the body wrote anything, so delegating an absent peer would
|
|
539
|
+
// fire a spurious membership notification.
|
|
540
|
+
// NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
|
|
541
|
+
// between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
|
|
542
|
+
// window only — the common "already absent" case is caught here.
|
|
543
|
+
const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
|
|
544
|
+
if (stampId === null) {
|
|
545
|
+
log('removePeer: no CadrePeer row for %s (already absent)', peerId);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
403
548
|
|
|
404
549
|
log('Removing peer: %s', peerId);
|
|
405
550
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
with context AuthorityKey = ?, Signature = ?
|
|
410
|
-
where PeerId = ?
|
|
411
|
-
`, [this.authorityPublicKey, signature, peerId]);
|
|
551
|
+
await this.controlDatabase.deleteCadrePeer(
|
|
552
|
+
peerId, ownerKey, message => this.signMessageBytes(message),
|
|
553
|
+
);
|
|
412
554
|
|
|
413
|
-
log('Peer %s removed successfully', peerId);
|
|
555
|
+
log('Peer %s removed successfully (stamp retired)', peerId);
|
|
414
556
|
}
|
|
415
557
|
|
|
416
558
|
/**
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
559
|
+
* Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
|
|
560
|
+
* re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
|
|
561
|
+
* the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
|
|
562
|
+
* a membership row that committed local-only (its block's cluster ≤1 at insert) is
|
|
563
|
+
* pushed to the cohort once it grows.
|
|
421
564
|
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
* `PeerId` PK. Only the freshness stamp changes — `PublicKey` / `Multiaddr` /
|
|
428
|
-
* `Sig` are left intact — so it is safe over a row whose peer has not
|
|
429
|
-
* self-published (`Sig` null); the caller must skip a row that already carries a
|
|
430
|
-
* self-`Sig` (that row is the owning peer's to refresh, and bumping `UpdatedAt`
|
|
431
|
-
* without re-signing would invalidate its self-signature).
|
|
565
|
+
* The stamp read, the voucher signature, the write lock and the membership notify are
|
|
566
|
+
* {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
|
|
567
|
+
* `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
|
|
568
|
+
*
|
|
569
|
+
* A no-op (no throw, no notify) when the row does not exist.
|
|
432
570
|
*
|
|
433
571
|
* @param peerId - the membership row to re-touch.
|
|
434
572
|
* @param updatedAt - the strictly-increasing freshness stamp to write.
|
|
435
|
-
* @throws if no
|
|
573
|
+
* @throws if no owner private key is configured (a non-owner cannot
|
|
436
574
|
* re-sign another peer's row) or the control database is not initialized.
|
|
437
575
|
*/
|
|
438
576
|
async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
const
|
|
577
|
+
// Fail fast on a keyless service before any DB read (see removePeer): a non-owner
|
|
578
|
+
// cannot re-sign the voucher, and must not silently no-op on an absent row.
|
|
579
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
442
580
|
if (!this.controlDatabase) {
|
|
443
581
|
throw new Error('Control database not initialized');
|
|
444
582
|
}
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
583
|
+
const retouched = await this.controlDatabase.reauthorizeCadrePeer(
|
|
584
|
+
peerId, updatedAt, ownerKey, message => this.signMessageBytes(message),
|
|
585
|
+
);
|
|
586
|
+
if (!retouched) {
|
|
587
|
+
log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
452
590
|
log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
|
|
453
591
|
}
|
|
454
592
|
|
|
593
|
+
/**
|
|
594
|
+
* Owner re-issue of a batch of `Revocation` tombstones: bump each row's
|
|
595
|
+
* `ReissuedAt` so the storage layer re-broadcasts a tombstone that committed
|
|
596
|
+
* while the node was alone. The delete-while-alone counterpart of
|
|
597
|
+
* {@link reauthorizePeer} — a removed row cannot be re-touched (it is gone),
|
|
598
|
+
* but its tombstone can, and every membership read treats a retired stamp as
|
|
599
|
+
* absent.
|
|
600
|
+
*
|
|
601
|
+
* The signatures, the single transaction, and the strictly-monotonic
|
|
602
|
+
* `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
|
|
603
|
+
* stays here is the owner-key precondition.
|
|
604
|
+
*
|
|
605
|
+
* @returns how many tombstones were re-issued (`rows.length` on success).
|
|
606
|
+
* @throws if no owner private key is configured or the control database is not
|
|
607
|
+
* initialized.
|
|
608
|
+
*/
|
|
609
|
+
async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number): Promise<number> {
|
|
610
|
+
// Fail fast on a keyless service before any DB work (see removePeer): a
|
|
611
|
+
// non-owner cannot sign the reissue digests.
|
|
612
|
+
const ownerKey = this.requireOwnerPublicKey();
|
|
613
|
+
if (!this.controlDatabase) {
|
|
614
|
+
throw new Error('Control database not initialized');
|
|
615
|
+
}
|
|
616
|
+
return this.controlDatabase.reissueRevocations(
|
|
617
|
+
rows, reissuedAt, ownerKey, message => this.signMessageBytes(message),
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
455
621
|
/**
|
|
456
622
|
* Create a seed from the current control network state.
|
|
457
|
-
* The seed contains peer information and is signed by an
|
|
623
|
+
* The seed contains peer information and is signed by an owner.
|
|
458
624
|
*/
|
|
459
625
|
async createSeed(): Promise<ControlNetworkSeed> {
|
|
460
|
-
if (!this.config.
|
|
461
|
-
throw new Error('
|
|
626
|
+
if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
|
|
627
|
+
throw new Error('Owner key required to create seeds');
|
|
462
628
|
}
|
|
463
629
|
|
|
464
630
|
if (!this.controlDatabase || !this.libp2pNode) {
|
|
@@ -481,7 +647,7 @@ export class SeedBootstrapService {
|
|
|
481
647
|
const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
|
|
482
648
|
const signature = sign(
|
|
483
649
|
seedDigest,
|
|
484
|
-
this.config.
|
|
650
|
+
this.config.ownerPrivateKey,
|
|
485
651
|
'ed25519',
|
|
486
652
|
'base64url',
|
|
487
653
|
'base64url',
|
|
@@ -491,7 +657,7 @@ export class SeedBootstrapService {
|
|
|
491
657
|
const seed: ControlNetworkSeed = {
|
|
492
658
|
...seedData,
|
|
493
659
|
signature,
|
|
494
|
-
signerKey: this.
|
|
660
|
+
signerKey: this.ownerPublicKey,
|
|
495
661
|
};
|
|
496
662
|
|
|
497
663
|
log('Created seed with %d peers', peers.length);
|
|
@@ -503,10 +669,15 @@ export class SeedBootstrapService {
|
|
|
503
669
|
*
|
|
504
670
|
* Validates the seed signature, then evaluates a trust anchor for the
|
|
505
671
|
* `signerKey` that does NOT come from the seed body: the receiver's
|
|
506
|
-
*
|
|
507
|
-
* TOFU via the configured/overriding
|
|
508
|
-
* self-asserting seed — one that merely lists its
|
|
509
|
-
* peer — no longer passes
|
|
672
|
+
* node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
|
|
673
|
+
* augmented by pinned keys or TOFU via the configured/overriding
|
|
674
|
+
* `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
|
|
675
|
+
* own signer as an owner peer — no longer passes, and neither does one signed
|
|
676
|
+
* by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
|
|
677
|
+
*
|
|
678
|
+
* A key accepted via a pin/TOFU is persisted into the anchor (the policy says
|
|
679
|
+
* so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
|
|
680
|
+
* anchored without re-supplying the invite.
|
|
510
681
|
*
|
|
511
682
|
* @param seed - The seed to apply (already transport-decoded).
|
|
512
683
|
* @param options.trustPolicy - Per-call policy override (e.g. a
|
|
@@ -518,35 +689,39 @@ export class SeedBootstrapService {
|
|
|
518
689
|
options?: { trustPolicy?: SeedTrustPolicy }
|
|
519
690
|
): Promise<ApplySeedResult> {
|
|
520
691
|
if (!this.libp2pNode) {
|
|
521
|
-
return
|
|
692
|
+
return seedRejected('Service not initialized');
|
|
522
693
|
}
|
|
523
694
|
|
|
695
|
+
// NOTE: `seed.partyId` is never checked against `config.partyId`. Nothing
|
|
696
|
+
// downstream reads it — trust is keyed on `signerKey` vs the anchor, and the
|
|
697
|
+
// anchor a stray-party seed could write into belongs to THIS party, which
|
|
698
|
+
// only a caller-supplied pin for that signer can reach. If applying a seed
|
|
699
|
+
// ever branches on its partyId (or the anchor becomes multi-party), reject a
|
|
700
|
+
// mismatch here instead.
|
|
524
701
|
log('Applying seed for party: %s', seed.partyId);
|
|
525
702
|
|
|
526
703
|
// Validate the seed signature
|
|
527
704
|
if (!this.validateSeedSignature(seed)) {
|
|
528
|
-
return
|
|
705
|
+
return seedRejected('Invalid seed signature');
|
|
529
706
|
}
|
|
530
707
|
|
|
531
|
-
// Evaluate the trust anchor for the signer key. The known-
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
708
|
+
// Evaluate the trust anchor for the signer key. The known-owner set comes
|
|
709
|
+
// from the receiver's NODE-LOCAL anchor — never from the seed itself, and
|
|
710
|
+
// never from the replicated OwnerKey table (a stranger can genesis-insert
|
|
711
|
+
// its own key there and let it replicate into every peer's copy). A node
|
|
712
|
+
// whose anchor was never seeded, with no policy override, sees an empty set
|
|
713
|
+
// and rejects.
|
|
714
|
+
const knownOwnerKeys = this.config.trustedOwners?.all() ?? new Set<string>();
|
|
537
715
|
const policy = options?.trustPolicy ?? this.trustPolicy;
|
|
538
716
|
const decision = await policy.evaluate({
|
|
539
717
|
partyId: seed.partyId,
|
|
540
718
|
signerKey: seed.signerKey,
|
|
541
|
-
|
|
719
|
+
knownOwnerKeys,
|
|
542
720
|
});
|
|
543
721
|
if (!decision.trusted) {
|
|
544
|
-
return
|
|
545
|
-
success: false,
|
|
546
|
-
peersAdded: 0,
|
|
547
|
-
error: decision.reason ?? 'Signer key not trusted by trust policy',
|
|
548
|
-
};
|
|
722
|
+
return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
|
|
549
723
|
}
|
|
724
|
+
await this.anchorAcceptedSigner(seed.signerKey, decision);
|
|
550
725
|
|
|
551
726
|
let peersAdded = 0;
|
|
552
727
|
|
|
@@ -570,23 +745,71 @@ export class SeedBootstrapService {
|
|
|
570
745
|
}
|
|
571
746
|
}
|
|
572
747
|
|
|
573
|
-
// Dial
|
|
574
|
-
|
|
748
|
+
// Dial owner peers to establish connections. Best-effort and COUNTED: an
|
|
749
|
+
// owner that is momentarily down leaves this node seeded but unconnected,
|
|
750
|
+
// which the caller can only see if the outcome is reported (see
|
|
751
|
+
// `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
|
|
752
|
+
// `CadreNode.dialColdStartBootstrap` retries these same addresses on every
|
|
753
|
+
// control-cohort reconcile pass until the control database has siblings.
|
|
754
|
+
// `createSeed` projects EVERY CadrePeer row, so an owner applying a seed
|
|
755
|
+
// minted after it joined finds ITSELF in the owner list. Dialing self always
|
|
756
|
+
// throws, which would report a healthy owner as "seeded but stranded".
|
|
757
|
+
// Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
|
|
758
|
+
// and an undefined self simply matches nothing.
|
|
759
|
+
const selfPeerId = this.libp2pNode.peerId?.toString();
|
|
760
|
+
let ownerDialsAttempted = 0;
|
|
761
|
+
let ownerDialsFailed = 0;
|
|
762
|
+
for (const peer of seed.peers.filter(p => p.isOwner)) {
|
|
763
|
+
if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
ownerDialsAttempted++;
|
|
575
767
|
try {
|
|
576
|
-
|
|
577
|
-
const addr = multiaddr(peer.multiaddrs[0]);
|
|
768
|
+
const addr = multiaddr(peer.multiaddrs[0]);
|
|
578
769
|
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
}
|
|
770
|
+
log('Dialing owner peer: %s', peer.peerId);
|
|
771
|
+
await this.libp2pNode.dial(addr);
|
|
582
772
|
} catch (error) {
|
|
773
|
+
ownerDialsFailed++;
|
|
583
774
|
log('Failed to dial peer %s: %o', peer.peerId, error);
|
|
584
775
|
// Continue - not all peers need to be reachable
|
|
585
776
|
}
|
|
586
777
|
}
|
|
587
778
|
|
|
588
|
-
log('Applied seed: %d peers added
|
|
589
|
-
|
|
779
|
+
log('Applied seed: %d peers added, %d/%d owner dial(s) failed',
|
|
780
|
+
peersAdded, ownerDialsFailed, ownerDialsAttempted);
|
|
781
|
+
return { success: true, peersAdded, ownerDialsAttempted, ownerDialsFailed };
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
|
|
786
|
+
* later seed from the same owner is anchored without re-supplying the invite
|
|
787
|
+
* or re-prompting. Only the policy decides this happens (`anchorAs` is unset
|
|
788
|
+
* when the key was already anchored, so a plain re-apply writes nothing) and
|
|
789
|
+
* `trust()` is idempotent, keeping the original provenance for a known key.
|
|
790
|
+
*
|
|
791
|
+
* Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
|
|
792
|
+
* in-memory anchor synchronously, so this seed and the rest of the session are
|
|
793
|
+
* unaffected — only durability across a restart is lost, and that is logged.
|
|
794
|
+
*
|
|
795
|
+
* NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
|
|
796
|
+
* unauthorized to authorized, which the write-driven membership-gate refresh
|
|
797
|
+
* (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. Every
|
|
798
|
+
* anchor mutation today rides seed application, and both seed paths refresh the
|
|
799
|
+
* gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`). If some
|
|
800
|
+
* future path anchors an owner OUTSIDE seed application, it owes the same
|
|
801
|
+
* `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
|
|
802
|
+
*/
|
|
803
|
+
private async anchorAcceptedSigner(signerKey: string, decision: SeedTrustDecision): Promise<void> {
|
|
804
|
+
if (!decision.anchorAs || !this.config.trustedOwners) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
try {
|
|
808
|
+
await this.config.trustedOwners.trust(signerKey, decision.anchorAs);
|
|
809
|
+
log('Anchored seed signer %s as %s', signerKey, decision.anchorAs);
|
|
810
|
+
} catch (error) {
|
|
811
|
+
log('Failed to persist accepted seed signer into the trusted-owner anchor: %o', error);
|
|
812
|
+
}
|
|
590
813
|
}
|
|
591
814
|
|
|
592
815
|
/**
|
|
@@ -608,61 +831,86 @@ export class SeedBootstrapService {
|
|
|
608
831
|
|
|
609
832
|
/**
|
|
610
833
|
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
834
|
+
*
|
|
835
|
+
* Sender hardening: the whole exchange — dial, write, ack read — is bounded by
|
|
836
|
+
* {@link seedDeliverTimeoutMs}, and the ack is capped at {@link MAX_SEED_SIZE}.
|
|
837
|
+
* The target is a NOT-YET-TRUSTED node the instigator chose to dial during
|
|
838
|
+
* onboarding, so an unbounded read here is strictly more exposed than the
|
|
839
|
+
* membership-gated receiver paths: without the bound a target that accepts the
|
|
840
|
+
* stream and never replies parks this call forever, and one that streams
|
|
841
|
+
* arbitrary bytes as a fake ack exhausts memory.
|
|
611
842
|
*/
|
|
612
843
|
async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<SeedAckMessage> {
|
|
613
844
|
if (!this.libp2pNode) {
|
|
614
845
|
throw new Error('Service not initialized');
|
|
615
846
|
}
|
|
616
|
-
|
|
847
|
+
// Capture the node so the closure below needs no non-null assertion.
|
|
848
|
+
const node = this.libp2pNode;
|
|
617
849
|
const addr = multiaddr(targetMultiaddr);
|
|
618
850
|
|
|
619
851
|
log('Delivering seed to: %s', targetMultiaddr);
|
|
620
852
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
const message: SeedMessage = {
|
|
628
|
-
partyId: seed.partyId,
|
|
629
|
-
peers: seed.peers,
|
|
630
|
-
signature: seed.signature,
|
|
631
|
-
signerKey: seed.signerKey,
|
|
632
|
-
};
|
|
633
|
-
|
|
634
|
-
writeFrame(stream, message);
|
|
635
|
-
|
|
636
|
-
// In libp2p v3.x, close() closes the write end only (signals EOF),
|
|
637
|
-
// while the read end remains open for receiving the ack.
|
|
638
|
-
await stream.close();
|
|
853
|
+
return await withDeadline(
|
|
854
|
+
this.seedDeliverTimeoutMs,
|
|
855
|
+
`Seed delivery to ${targetMultiaddr}`,
|
|
856
|
+
(signal) => this.sendSeed(node, addr, seed, signal),
|
|
857
|
+
);
|
|
858
|
+
}
|
|
639
859
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
860
|
+
/**
|
|
861
|
+
* Open one stream to the target, send the seed frame, half-close, and read the ack.
|
|
862
|
+
*
|
|
863
|
+
* `signal` is the deadline from {@link deliverSeed}: it goes to `dialProtocol` so
|
|
864
|
+
* a timeout during connect aborts the dial, and into {@link exchangeFrame} so a
|
|
865
|
+
* timeout after the stream is open resets it — releasing the otherwise unbounded
|
|
866
|
+
* ack-read.
|
|
867
|
+
*
|
|
868
|
+
* Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
|
|
869
|
+
* tiny frame over a relay, whereas a seed is up to 1MB and this delivery path
|
|
870
|
+
* does not dial relay addresses today. Changing that is a separate decision.
|
|
871
|
+
*/
|
|
872
|
+
private async sendSeed(
|
|
873
|
+
node: Libp2p,
|
|
874
|
+
addr: Multiaddr,
|
|
875
|
+
seed: ControlNetworkSeed,
|
|
876
|
+
signal: AbortSignal,
|
|
877
|
+
): Promise<SeedAckMessage> {
|
|
878
|
+
const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
|
|
646
879
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
880
|
+
const message: SeedMessage = {
|
|
881
|
+
partyId: seed.partyId,
|
|
882
|
+
peers: seed.peers,
|
|
883
|
+
signature: seed.signature,
|
|
884
|
+
signerKey: seed.signerKey,
|
|
885
|
+
};
|
|
653
886
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
887
|
+
const ack = await exchangeFrame(
|
|
888
|
+
rawStream as unknown as ControlStream,
|
|
889
|
+
signal,
|
|
890
|
+
message,
|
|
891
|
+
(stream) => this.readSeedAck(stream),
|
|
892
|
+
'Seed delivery aborted by timeout',
|
|
893
|
+
);
|
|
658
894
|
|
|
659
|
-
|
|
660
|
-
|
|
895
|
+
log('Seed delivery response: accepted=%s', ack.accepted);
|
|
896
|
+
return ack;
|
|
897
|
+
}
|
|
661
898
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
899
|
+
/**
|
|
900
|
+
* Read the ack frame a delivery target writes back, bounded by
|
|
901
|
+
* {@link seedDeliverTimeoutMs} and capped at {@link MAX_SEED_SIZE} — an
|
|
902
|
+
* untrusted target must not be able to stream unlimited bytes as a fake ack.
|
|
903
|
+
* Decoding runs inside {@link exchangeFrame}'s `try`, so a malformed or
|
|
904
|
+
* non-JSON ack resets the stream rather than leaking it.
|
|
905
|
+
*/
|
|
906
|
+
private async readSeedAck(stream: ControlStream): Promise<SeedAckMessage> {
|
|
907
|
+
const data = await readStreamToEnd(stream, {
|
|
908
|
+
maxBytes: MAX_SEED_SIZE,
|
|
909
|
+
timeoutMs: this.seedDeliverTimeoutMs,
|
|
910
|
+
label: 'Seed ack',
|
|
911
|
+
});
|
|
912
|
+
const body = decodeLengthPrefixedFrame(data, MAX_SEED_SIZE);
|
|
913
|
+
return JSON.parse(new TextDecoder().decode(body)) as SeedAckMessage;
|
|
666
914
|
}
|
|
667
915
|
|
|
668
916
|
/**
|
|
@@ -711,19 +959,27 @@ export class SeedBootstrapService {
|
|
|
711
959
|
/**
|
|
712
960
|
* Query peers from the control database.
|
|
713
961
|
*
|
|
714
|
-
*
|
|
962
|
+
* Owner identity is sourced from the `OwnerKey` table, not from the
|
|
715
963
|
* transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
|
|
716
964
|
* multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
|
|
717
|
-
* peer is an
|
|
718
|
-
* This makes any
|
|
719
|
-
* `
|
|
965
|
+
* peer is an owner iff that derived key is in the `OwnerKey` set.
|
|
966
|
+
* This makes any owner node markable — not just the local one — and ties
|
|
967
|
+
* `isOwner` to the control table rather than to `peerId === self`.
|
|
968
|
+
*
|
|
969
|
+
* NOTE: this is the one owner lookup deliberately left on the REPLICATED
|
|
970
|
+
* table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
|
|
971
|
+
* — the receiver dials owner-flagged peers first — not a trust decision, and
|
|
972
|
+
* the receiver re-derives real trust from its own anchor. So a polluted table
|
|
973
|
+
* costs at most a wasted dial, while anchoring here would silently drop
|
|
974
|
+
* legitimate co-owners this node never pinned. If `isOwner` ever gates
|
|
975
|
+
* anything the receiver TRUSTS, move it to the anchor.
|
|
720
976
|
*/
|
|
721
977
|
private async queryPeers(): Promise<SeedPeer[]> {
|
|
722
978
|
if (!this.controlDatabase) {
|
|
723
979
|
return [];
|
|
724
980
|
}
|
|
725
981
|
|
|
726
|
-
const
|
|
982
|
+
const ownerKeys = await this.controlDatabase.getOwnerKeys();
|
|
727
983
|
const db = this.controlDatabase.getDatabase();
|
|
728
984
|
const peers: SeedPeer[] = [];
|
|
729
985
|
|
|
@@ -733,16 +989,16 @@ export class SeedBootstrapService {
|
|
|
733
989
|
const multiaddr = row.Multiaddr as string | null;
|
|
734
990
|
|
|
735
991
|
// Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
|
|
736
|
-
// unparsable id yields null and is treated as a non-
|
|
992
|
+
// unparsable id yields null and is treated as a non-owner rather than
|
|
737
993
|
// failing the whole seed creation.
|
|
738
994
|
const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
|
|
739
|
-
const
|
|
995
|
+
const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
|
|
740
996
|
|
|
741
997
|
peers.push({
|
|
742
998
|
peerId,
|
|
743
999
|
multiaddrs: multiaddr ? multiaddr.split(',') : [],
|
|
744
|
-
|
|
745
|
-
...(
|
|
1000
|
+
isOwner,
|
|
1001
|
+
...(isOwner ? { publicKey: pubKeyB64 } : {}),
|
|
746
1002
|
});
|
|
747
1003
|
}
|
|
748
1004
|
|
|
@@ -821,7 +1077,7 @@ export class SeedBootstrapService {
|
|
|
821
1077
|
|
|
822
1078
|
// Emit appropriate event based on result
|
|
823
1079
|
if (result.success) {
|
|
824
|
-
this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
|
|
1080
|
+
this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded, seed);
|
|
825
1081
|
} else {
|
|
826
1082
|
this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
|
|
827
1083
|
}
|
|
@@ -921,29 +1177,37 @@ export class SeedBootstrapService {
|
|
|
921
1177
|
// configured (typically by `@serfab/cadre-host`'s NatService), it takes
|
|
922
1178
|
// priority — it may substitute a DDNS hostname and externally-mapped
|
|
923
1179
|
// port for the raw LAN multiaddrs libp2p reports.
|
|
924
|
-
let
|
|
1180
|
+
let ownerAddrs: string[];
|
|
925
1181
|
if (this.config.inviteAddressResolver) {
|
|
926
1182
|
try {
|
|
927
|
-
|
|
1183
|
+
ownerAddrs = await this.config.inviteAddressResolver();
|
|
928
1184
|
} catch (err) {
|
|
929
1185
|
log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
|
|
930
|
-
|
|
1186
|
+
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
931
1187
|
}
|
|
932
1188
|
} else {
|
|
933
|
-
|
|
1189
|
+
ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
|
|
934
1190
|
}
|
|
935
1191
|
|
|
936
|
-
// Carry the cadre's
|
|
937
|
-
//
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1192
|
+
// Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
|
|
1193
|
+
// the trusted owner set before applying any seed.
|
|
1194
|
+
//
|
|
1195
|
+
// Sourced ONLY from this node's own anchor, never from the replicated
|
|
1196
|
+
// OwnerKey table: the invitee anchors whatever arrives here
|
|
1197
|
+
// (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
|
|
1198
|
+
// pollutable table would let a stranger's genesis-inserted key ride an
|
|
1199
|
+
// otherwise-legitimate invite straight into the new node's anchor —
|
|
1200
|
+
// poisoning the very store this whole trust chain rests on. No anchor wired
|
|
1201
|
+
// (a directly-constructed service) means no pins to hand out: an invite
|
|
1202
|
+
// without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
|
|
1203
|
+
// table-sourced one silently hands it an unanchored key.
|
|
1204
|
+
const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
|
|
941
1205
|
|
|
942
1206
|
const now = Date.now();
|
|
943
1207
|
const invite: CadreInvite = {
|
|
944
1208
|
partyId: this.config.partyId,
|
|
945
|
-
|
|
946
|
-
|
|
1209
|
+
ownerAddrs,
|
|
1210
|
+
ownerKeys: ownerKeys.length ? ownerKeys : undefined,
|
|
947
1211
|
token,
|
|
948
1212
|
createdAt: now,
|
|
949
1213
|
expiresAt: expiresIn ? now + expiresIn : undefined,
|
|
@@ -951,7 +1215,7 @@ export class SeedBootstrapService {
|
|
|
951
1215
|
|
|
952
1216
|
const encodedInvite = this.encodeInvite(invite);
|
|
953
1217
|
|
|
954
|
-
log('Invite created with %d
|
|
1218
|
+
log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
|
|
955
1219
|
|
|
956
1220
|
return { invite, encodedInvite };
|
|
957
1221
|
}
|
|
@@ -1045,11 +1309,11 @@ export class SeedBootstrapService {
|
|
|
1045
1309
|
}
|
|
1046
1310
|
|
|
1047
1311
|
/**
|
|
1048
|
-
* Dial an
|
|
1049
|
-
* Use this on a phone after receiving an invite to connect to the
|
|
1312
|
+
* Dial an owner from an invite.
|
|
1313
|
+
* Use this on a phone after receiving an invite to connect to the owner.
|
|
1050
1314
|
*
|
|
1051
1315
|
* @param invite - The invite received out-of-band
|
|
1052
|
-
* @returns Connection to the
|
|
1316
|
+
* @returns Connection to the owner
|
|
1053
1317
|
*/
|
|
1054
1318
|
async dialInvite(invite: CadreInvite): Promise<void> {
|
|
1055
1319
|
if (!this.libp2pNode) {
|
|
@@ -1061,15 +1325,15 @@ export class SeedBootstrapService {
|
|
|
1061
1325
|
throw new Error('Invite has expired');
|
|
1062
1326
|
}
|
|
1063
1327
|
|
|
1064
|
-
log('Dialing invite
|
|
1328
|
+
log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
|
|
1065
1329
|
|
|
1066
|
-
// Try each
|
|
1330
|
+
// Try each owner address until one succeeds
|
|
1067
1331
|
let lastError: Error | null = null;
|
|
1068
|
-
for (const addrStr of invite.
|
|
1332
|
+
for (const addrStr of invite.ownerAddrs) {
|
|
1069
1333
|
try {
|
|
1070
1334
|
const addr = multiaddr(addrStr);
|
|
1071
1335
|
await this.libp2pNode.dial(addr);
|
|
1072
|
-
log('Connected to
|
|
1336
|
+
log('Connected to owner at: %s', addrStr);
|
|
1073
1337
|
return;
|
|
1074
1338
|
} catch (error) {
|
|
1075
1339
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
@@ -1077,7 +1341,7 @@ export class SeedBootstrapService {
|
|
|
1077
1341
|
}
|
|
1078
1342
|
}
|
|
1079
1343
|
|
|
1080
|
-
throw lastError ?? new Error('No
|
|
1344
|
+
throw lastError ?? new Error('No owner addresses available');
|
|
1081
1345
|
}
|
|
1082
1346
|
}
|
|
1083
1347
|
|