@serfab/cadre-core 1.16.0 → 1.17.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 +1 -1
- package/dist/bootstrap-peer-store-file.js +1 -1
- package/dist/bootstrap-peer-store.d.ts +1 -1
- package/dist/bootstrap-peer-store.js +4 -4
- package/dist/cadre-invite-protocol.d.ts +55 -138
- package/dist/cadre-invite-protocol.js +94 -449
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +15 -2
- package/dist/cadre-join-request.js +22 -3
- package/dist/cadre-join-request.js.map +1 -1
- package/dist/cadre-node.d.ts +530 -247
- package/dist/cadre-node.js +1046 -525
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +36 -14
- package/dist/control-authorization.js +20 -3
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +22 -23
- package/dist/control-cohort.js +24 -24
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +416 -136
- package/dist/control-database.js +757 -378
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +5 -4
- package/dist/control-protocol-guard.js +6 -3
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +80 -0
- package/dist/control-schema-version.js +203 -0
- package/dist/control-schema-version.js.map +1 -0
- package/dist/control-schema.d.ts +45 -5
- package/dist/control-schema.js +254 -70
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +5 -5
- package/dist/control-write-retry.js +10 -8
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +13 -0
- package/dist/delegate-admission.js +27 -0
- package/dist/delegate-admission.js.map +1 -1
- package/dist/ed25519-key.d.ts +10 -7
- package/dist/ed25519-key.js +23 -20
- package/dist/ed25519-key.js.map +1 -1
- package/dist/edge-node/config.d.ts +122 -0
- package/dist/edge-node/config.js +199 -0
- package/dist/edge-node/config.js.map +1 -0
- package/dist/edge-node/dissolve.d.ts +95 -0
- package/dist/edge-node/dissolve.js +184 -0
- package/dist/edge-node/dissolve.js.map +1 -0
- package/dist/edge-node/edge-join.d.ts +44 -0
- package/dist/edge-node/edge-join.js +227 -0
- package/dist/edge-node/edge-join.js.map +1 -0
- package/dist/edge-node/edge-node.d.ts +295 -0
- package/dist/edge-node/edge-node.js +704 -0
- package/dist/edge-node/edge-node.js.map +1 -0
- package/dist/edge-node/index.d.ts +18 -0
- package/dist/edge-node/index.js +18 -0
- package/dist/edge-node/index.js.map +1 -0
- package/dist/edge-node/join-credential.d.ts +11 -0
- package/dist/edge-node/join-credential.js +11 -0
- package/dist/edge-node/join-credential.js.map +1 -0
- package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
- package/dist/edge-node/lazy-raw-storage.js +134 -0
- package/dist/edge-node/lazy-raw-storage.js.map +1 -0
- package/dist/edge-node/node-local.d.ts +60 -0
- package/dist/edge-node/node-local.js +58 -0
- package/dist/edge-node/node-local.js.map +1 -0
- package/dist/edge-node/options.d.ts +83 -0
- package/dist/edge-node/options.js +110 -0
- package/dist/edge-node/options.js.map +1 -0
- package/dist/edge-node/owner-grant.d.ts +41 -0
- package/dist/edge-node/owner-grant.js +38 -0
- package/dist/edge-node/owner-grant.js.map +1 -0
- package/dist/edge-node/platform.d.ts +140 -0
- package/dist/edge-node/platform.js +12 -0
- package/dist/edge-node/platform.js.map +1 -0
- package/dist/edge-node/strand-move.d.ts +115 -0
- package/dist/edge-node/strand-move.js +223 -0
- package/dist/edge-node/strand-move.js.map +1 -0
- package/dist/edge-node/strands.d.ts +28 -0
- package/dist/edge-node/strands.js +60 -0
- package/dist/edge-node/strands.js.map +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.js +26 -7
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +3 -3
- package/dist/joined-strand-store.js +3 -3
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store.d.ts +9 -4
- package/dist/key-store.js +9 -4
- package/dist/key-store.js.map +1 -1
- package/dist/machine-removal.d.ts +19 -39
- package/dist/machine-removal.js +24 -98
- package/dist/machine-removal.js.map +1 -1
- package/dist/member-redemption.d.ts +250 -0
- package/dist/member-redemption.js +485 -0
- package/dist/member-redemption.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +4 -4
- package/dist/owner-anchor-sync.js +16 -15
- package/dist/owner-anchor-sync.js.map +1 -1
- package/dist/owner-grant-protocol.d.ts +247 -0
- package/dist/owner-grant-protocol.js +330 -0
- package/dist/owner-grant-protocol.js.map +1 -0
- package/dist/owner-key-export.d.ts +70 -0
- package/dist/owner-key-export.js +279 -0
- package/dist/owner-key-export.js.map +1 -0
- package/dist/owner-key-flows.d.ts +55 -0
- package/dist/owner-key-flows.js +60 -0
- package/dist/owner-key-flows.js.map +1 -0
- package/dist/owner-key-store.d.ts +31 -0
- package/dist/owner-key-store.js +81 -0
- package/dist/owner-key-store.js.map +1 -0
- package/dist/owner-keys.d.ts +78 -0
- package/dist/owner-keys.js +133 -0
- package/dist/owner-keys.js.map +1 -0
- package/dist/owner-signer.d.ts +54 -0
- package/dist/owner-signer.js +51 -0
- package/dist/owner-signer.js.map +1 -0
- package/dist/peer-authorization.d.ts +83 -18
- package/dist/peer-authorization.js +123 -53
- package/dist/peer-authorization.js.map +1 -1
- package/dist/provider/rent-node.js +1 -1
- package/dist/provider/rent-node.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +131 -109
- package/dist/seed-bootstrap.js +260 -248
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.js +4 -4
- package/dist/strand-addr-protocol.d.ts +4 -1
- package/dist/strand-addr-protocol.js +8 -2
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-cohort.d.ts +3 -0
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +1 -0
- package/dist/strand-database.js +3 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +4 -3
- package/dist/strand-first-sync-gate.js +4 -3
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +1 -1
- package/dist/strand-membership-writer.d.ts +13 -2
- package/dist/strand-membership-writer.js +52 -16
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.d.ts +6 -5
- package/dist/strand-move.js +31 -30
- package/dist/strand-move.js.map +1 -1
- package/dist/strand-peer-redial.js +2 -3
- package/dist/strand-peer-redial.js.map +1 -1
- package/dist/types.d.ts +286 -103
- package/dist/types.js.map +1 -1
- package/package.json +11 -2
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +214 -214
- package/src/cadre-invite-protocol.ts +652 -1052
- package/src/cadre-join-request.ts +24 -3
- package/src/cadre-node.ts +1166 -541
- package/src/control-authorization.ts +47 -14
- package/src/control-cohort.ts +127 -128
- package/src/control-database.ts +940 -456
- package/src/control-protocol-guard.ts +7 -4
- package/src/control-schema-version.ts +251 -0
- package/src/control-schema.ts +265 -70
- package/src/control-write-retry.ts +569 -567
- package/src/delegate-admission.ts +37 -0
- package/src/ed25519-key.ts +174 -171
- package/src/edge-node/config.ts +277 -0
- package/src/edge-node/dissolve.ts +251 -0
- package/src/edge-node/edge-join.ts +319 -0
- package/src/edge-node/edge-node.ts +1045 -0
- package/src/edge-node/index.ts +59 -0
- package/src/edge-node/join-credential.ts +16 -0
- package/src/edge-node/lazy-raw-storage.ts +178 -0
- package/src/edge-node/node-local.ts +84 -0
- package/src/edge-node/options.ts +179 -0
- package/src/edge-node/owner-grant.ts +63 -0
- package/src/edge-node/platform.ts +151 -0
- package/src/edge-node/strand-move.ts +270 -0
- package/src/edge-node/strands.ts +72 -0
- package/src/index.ts +886 -789
- package/src/joined-strand-store.ts +4 -4
- package/src/key-store.ts +10 -4
- package/src/machine-removal.ts +26 -111
- package/src/member-redemption.ts +642 -0
- package/src/owner-anchor-sync.ts +19 -18
- package/src/owner-grant-protocol.ts +532 -0
- package/src/owner-key-export.ts +348 -0
- package/src/owner-key-flows.ts +109 -0
- package/src/owner-key-store.ts +85 -0
- package/src/owner-keys.ts +178 -0
- package/src/owner-signer.ts +94 -0
- package/src/peer-authorization.ts +558 -488
- package/src/provider/rent-node.ts +1 -1
- package/src/seed-bootstrap.ts +301 -312
- package/src/seed-trust-policy.ts +4 -4
- package/src/strand-addr-protocol.ts +547 -538
- package/src/strand-cohort.ts +4 -0
- package/src/strand-database.ts +3 -0
- package/src/strand-first-sync-gate.ts +4 -3
- package/src/strand-instance-manager.ts +1 -1
- package/src/strand-membership-writer.ts +61 -16
- package/src/strand-move.ts +45 -36
- package/src/strand-peer-redial.ts +2 -3
- package/src/types.ts +304 -103
package/dist/seed-bootstrap.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
3
|
-
import { digest,
|
|
3
|
+
import { digest, verify } from '@optimystic/quereus-plugin-crypto';
|
|
4
4
|
import { multiaddr } from '@multiformats/multiaddr';
|
|
5
5
|
import { peerIdFromString } from '@libp2p/peer-id';
|
|
6
6
|
import { withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
|
|
7
7
|
import { dialPeerAddrs, SelfRelayOnlyError, PeerUnreachableError, DEFAULT_PEER_DIAL_BUDGET, withTrailingPeerId, } from './peer-dial.js';
|
|
8
8
|
import { relayedRequestBudgetMs } from './link-budget.js';
|
|
9
|
-
import { generateStampId } from './control-database.js';
|
|
9
|
+
import { generateStampId, ownerKeyLabel } from './control-database.js';
|
|
10
10
|
import { canonicalJson } from './canonical-json.js';
|
|
11
11
|
import { deviceTokenAddDigest } from './peer-authorization.js';
|
|
12
12
|
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
13
13
|
import { anchoredTrustPolicy, } from './seed-trust-policy.js';
|
|
14
|
+
import { withinOwnerApproval } from './owner-signer.js';
|
|
15
|
+
import { namedOwnerKey } from './owner-keys.js';
|
|
14
16
|
const log = debug('sereus:cadre:seed-bootstrap');
|
|
15
17
|
/** Protocol ID for seed delivery */
|
|
16
18
|
export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
|
|
@@ -70,8 +72,15 @@ export function canonicalSeedPayload(seed) {
|
|
|
70
72
|
export function seedDigest(seed) {
|
|
71
73
|
return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url');
|
|
72
74
|
}
|
|
75
|
+
/** Add `relayAddr` to the entry for `selfPeerId` in `peers`, when that entry exists and lacks it. */
|
|
76
|
+
function addSelfRelayAddr(peers, selfPeerId, relayAddr) {
|
|
77
|
+
const self = peers.find((peer) => peer.peerId === selfPeerId);
|
|
78
|
+
if (self && !self.multiaddrs.includes(relayAddr)) {
|
|
79
|
+
self.multiaddrs.push(relayAddr);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
73
82
|
/**
|
|
74
|
-
* An {@link ApplySeedResult} for a seed refused before the
|
|
83
|
+
* An {@link ApplySeedResult} for a seed refused before the dial loop ran,
|
|
75
84
|
* so the dial counters read zero rather than being absent.
|
|
76
85
|
*/
|
|
77
86
|
function seedRejected(error, code) {
|
|
@@ -80,8 +89,8 @@ function seedRejected(error, code) {
|
|
|
80
89
|
peersAdded: 0,
|
|
81
90
|
error,
|
|
82
91
|
...(code !== undefined ? { code } : {}),
|
|
83
|
-
|
|
84
|
-
|
|
92
|
+
preferredDialsAttempted: 0,
|
|
93
|
+
preferredDialsFailed: 0,
|
|
85
94
|
};
|
|
86
95
|
}
|
|
87
96
|
/**
|
|
@@ -162,13 +171,6 @@ export class SeedBootstrapService {
|
|
|
162
171
|
this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
|
|
163
172
|
this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? relayedRequestBudgetMs(config.linkRoundTripMs);
|
|
164
173
|
this.dialBudget = config.dialBudget ?? DEFAULT_PEER_DIAL_BUDGET;
|
|
165
|
-
// Derive public key from private key if not provided
|
|
166
|
-
if (config.ownerPrivateKey && !config.ownerPublicKey) {
|
|
167
|
-
this.ownerPublicKey = getPublicKey(config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url');
|
|
168
|
-
}
|
|
169
|
-
else {
|
|
170
|
-
this.ownerPublicKey = config.ownerPublicKey ?? null;
|
|
171
|
-
}
|
|
172
174
|
log('SeedBootstrapService created for party: %s', config.partyId);
|
|
173
175
|
}
|
|
174
176
|
/**
|
|
@@ -179,22 +181,25 @@ export class SeedBootstrapService {
|
|
|
179
181
|
this.eventCallbacks = callbacks;
|
|
180
182
|
}
|
|
181
183
|
/**
|
|
182
|
-
* Whether this service holds an owner
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
* drain to skip owner work on a non-owner node.
|
|
184
|
+
* Whether this service holds an owner signer, i.e. can produce the owner signatures that
|
|
185
|
+
* gate `CadrePeer` / `DeviceToken` inserts, deletes, and re-authorizations. A
|
|
186
|
+
* seed-listener-only service (`enableSeedListener`, no signer) returns false: it can
|
|
187
|
+
* receive/apply seeds but cannot author or re-issue owner writes. Used by the
|
|
188
|
+
* write-while-alone re-replication drain to skip owner work on a non-owner node.
|
|
188
189
|
*/
|
|
189
190
|
canAuthorize() {
|
|
190
|
-
return
|
|
191
|
+
return this.config.ownerSigner !== undefined;
|
|
192
|
+
}
|
|
193
|
+
/** The owner signer this service was built with, or null for a listener-only service. */
|
|
194
|
+
get ownerSigner() {
|
|
195
|
+
return this.config.ownerSigner ?? null;
|
|
191
196
|
}
|
|
192
197
|
/**
|
|
193
198
|
* Initialize the service with libp2p node and control database.
|
|
194
199
|
*
|
|
195
200
|
* `registerHandler` (default true) gates registration of the shared inbound
|
|
196
201
|
* `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
|
|
197
|
-
* (`
|
|
202
|
+
* (`CadreNode.useOwnerSigner`, `enableSeedListener`) own that handler and leave
|
|
198
203
|
* it on. The throwaway temp service CadreNode builds in `applySeed` passes
|
|
199
204
|
* `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
|
|
200
205
|
* dialing and known-key lookup, and must NOT bind a discarded closure to the
|
|
@@ -226,8 +231,11 @@ export class SeedBootstrapService {
|
|
|
226
231
|
* `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
|
|
227
232
|
* the peer's self-signature, so the peer must self-publish (see
|
|
228
233
|
* {@link CadreNode.registerSelf}) before the row resolves.
|
|
234
|
+
*
|
|
235
|
+
* @param sign - The signature function of an owner approval the caller already holds, so a
|
|
236
|
+
* multi-step action asks once; omitted, this write asks for its own approval.
|
|
229
237
|
*/
|
|
230
|
-
async authorizePeer(options) {
|
|
238
|
+
async authorizePeer(options, sign) {
|
|
231
239
|
const { peerId, multiaddrs } = options;
|
|
232
240
|
log('Authorizing peer: %s', peerId);
|
|
233
241
|
// CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
|
|
@@ -239,7 +247,7 @@ export class SeedBootstrapService {
|
|
|
239
247
|
updatedAt: Date.now(),
|
|
240
248
|
sig: null,
|
|
241
249
|
profile: null,
|
|
242
|
-
});
|
|
250
|
+
}, `Add the machine ${peerId} to the cadre`, sign);
|
|
243
251
|
log('Peer %s authorized successfully', peerId);
|
|
244
252
|
}
|
|
245
253
|
/**
|
|
@@ -255,7 +263,7 @@ export class SeedBootstrapService {
|
|
|
255
263
|
* in which case the row in the database is NOT this record and carries whatever
|
|
256
264
|
* `Sig` that writer had, so the caller must self-update to publish its signature.
|
|
257
265
|
*/
|
|
258
|
-
async insertSelfPeerRecord(record) {
|
|
266
|
+
async insertSelfPeerRecord(record, sign) {
|
|
259
267
|
return await this.insertCadrePeerRow({
|
|
260
268
|
peerId: record.peerId,
|
|
261
269
|
publicKey: record.publicKey,
|
|
@@ -263,30 +271,27 @@ export class SeedBootstrapService {
|
|
|
263
271
|
updatedAt: record.updatedAt,
|
|
264
272
|
sig: record.sig,
|
|
265
273
|
profile: record.profile,
|
|
266
|
-
});
|
|
274
|
+
}, 'Publish this machine\'s own address record', sign);
|
|
267
275
|
}
|
|
268
276
|
/**
|
|
269
|
-
* Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-
|
|
277
|
+
* Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-signer
|
|
270
278
|
* precondition; the stamp mint, voucher signature, write lock, existence check and
|
|
271
279
|
* membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
|
|
272
280
|
* the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
|
|
273
281
|
* method: the write is what admits the peer's traffic.
|
|
274
282
|
*
|
|
275
|
-
* The DB check precedes the owner-
|
|
276
|
-
* always surfaced them
|
|
277
|
-
* DB check).
|
|
283
|
+
* The DB check precedes the owner-signer check here, matching the order this method has
|
|
284
|
+
* always surfaced them.
|
|
278
285
|
*
|
|
279
286
|
* @returns `true` when this call performed the INSERT, `false` when the row was
|
|
280
287
|
* already seated by a concurrent writer. The loser needs to know: an authorize seats
|
|
281
288
|
* a row with a null `Sig`, so a self-publish that lost the race must fall through to
|
|
282
289
|
* a self-update or its record never lands.
|
|
283
290
|
*/
|
|
284
|
-
async insertCadrePeerRow(row) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const ownerKey = this.requireOwnerPublicKey();
|
|
289
|
-
return await this.controlDatabase.insertCadrePeer(row, ownerKey, message => this.signMessageBytes(message));
|
|
291
|
+
async insertCadrePeerRow(row, action, sign) {
|
|
292
|
+
const controlDatabase = this.requireControlDatabase();
|
|
293
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
294
|
+
return await this.withOwnerSign(action, sign, (approved) => controlDatabase.insertCadrePeer(row, ownerKey, approved));
|
|
290
295
|
}
|
|
291
296
|
/**
|
|
292
297
|
* Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
|
|
@@ -305,17 +310,17 @@ export class SeedBootstrapService {
|
|
|
305
310
|
* fresh signature — unaffected by the cleared row's retired stamp
|
|
306
311
|
* (`DeviceToken.NotRevoked`).
|
|
307
312
|
*/
|
|
308
|
-
async insertSelfDeviceToken(record) {
|
|
313
|
+
async insertSelfDeviceToken(record, sign) {
|
|
314
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
315
|
+
const controlDatabase = this.requireControlDatabase();
|
|
309
316
|
const stampId = generateStampId(record.peerId);
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
await this.controlDatabase.execWrite(`
|
|
317
|
+
const digestBytes = uint8ArrayFromString(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }), 'base64url');
|
|
318
|
+
const signature = await this.withOwnerSign('Publish this machine\'s push token', sign, (approved) => approved(digestBytes));
|
|
319
|
+
await controlDatabase.execWrite(`
|
|
315
320
|
insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
|
|
316
321
|
with context OwnerKey = ?, Signature = ?
|
|
317
322
|
values (?, ?, ?, ?, ?, ?)
|
|
318
|
-
`, [
|
|
323
|
+
`, [ownerKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId], 'device-token-insert');
|
|
319
324
|
log('Device token inserted (owner-signed): %s', record.peerId);
|
|
320
325
|
}
|
|
321
326
|
/**
|
|
@@ -336,66 +341,44 @@ export class SeedBootstrapService {
|
|
|
336
341
|
* Both digests, the stamp read, and the transaction come from
|
|
337
342
|
* {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
|
|
338
343
|
* `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
|
|
339
|
-
* owner-
|
|
344
|
+
* owner-signer precondition; a no-op on an already-absent row is the shared body's
|
|
340
345
|
* behavior, and unlike {@link removePeer} nothing here rides on it.
|
|
341
346
|
*/
|
|
342
|
-
async deleteDeviceToken(peerId) {
|
|
343
|
-
// Fail fast on a
|
|
344
|
-
// owner-
|
|
345
|
-
const ownerKey = this.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
await this.controlDatabase.deleteDeviceToken(peerId, ownerKey, message => this.signMessageBytes(message));
|
|
347
|
+
async deleteDeviceToken(peerId, sign) {
|
|
348
|
+
// Fail fast on a signerless service BEFORE the DB read, so a non-owner gets the
|
|
349
|
+
// owner-signer error rather than a silent no-op on an absent row.
|
|
350
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
351
|
+
const controlDatabase = this.requireControlDatabase();
|
|
352
|
+
await this.withOwnerSign(`Clear the push token of ${peerId}`, sign, (approved) => controlDatabase.deleteDeviceToken(peerId, ownerKey, approved));
|
|
350
353
|
}
|
|
351
354
|
/**
|
|
352
|
-
* Return the configured owner
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
*
|
|
355
|
+
* Return the configured owner signer, or throw if none is set. The single precondition
|
|
356
|
+
* gate for every owner-signed write. {@link removePeer} / {@link reauthorizePeer} read the
|
|
357
|
+
* row's `StampId` from the DB BEFORE they sign, so they call this up front — otherwise a
|
|
358
|
+
* signerless service would either surface the wrong "Control database not initialized"
|
|
359
|
+
* error or, worse, silently no-op when the target row is absent (the early
|
|
360
|
+
* `stampId === null` return) instead of rejecting.
|
|
358
361
|
*/
|
|
359
|
-
|
|
360
|
-
if (!this.config.
|
|
361
|
-
throw new Error('Owner
|
|
362
|
+
requireOwnerSigner() {
|
|
363
|
+
if (!this.config.ownerSigner) {
|
|
364
|
+
throw new Error('Owner signer required to authorize peers');
|
|
362
365
|
}
|
|
363
|
-
return this.config.
|
|
366
|
+
return this.config.ownerSigner;
|
|
364
367
|
}
|
|
365
|
-
/**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
* constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
|
|
370
|
-
* {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
|
|
371
|
-
* unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
|
|
372
|
-
* their first line and get the owner-key precondition for free.
|
|
373
|
-
*/
|
|
374
|
-
requireOwnerPublicKey() {
|
|
375
|
-
this.requireOwnerPrivateKey();
|
|
376
|
-
if (!this.ownerPublicKey) {
|
|
377
|
-
throw new Error('Owner public key required to authorize peers');
|
|
368
|
+
/** The control database {@link initialize} attached, or a throw. */
|
|
369
|
+
requireControlDatabase() {
|
|
370
|
+
if (!this.controlDatabase) {
|
|
371
|
+
throw new Error('Control database not initialized');
|
|
378
372
|
}
|
|
379
|
-
return this.
|
|
380
|
-
}
|
|
381
|
-
/**
|
|
382
|
-
* Sign a base64url digest with the owner key (ed25519). The single place the
|
|
383
|
-
* owner private key is applied; callers pass the canonical domain-tagged digest for
|
|
384
|
-
* the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
|
|
385
|
-
* {@link signMessageBytes}. Throws if no owner key is set.
|
|
386
|
-
*/
|
|
387
|
-
signDigest(digestB64url) {
|
|
388
|
-
return sign(digestB64url, this.requireOwnerPrivateKey(), 'ed25519', 'base64url', 'base64url', 'base64url');
|
|
373
|
+
return this.controlDatabase;
|
|
389
374
|
}
|
|
390
375
|
/**
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
|
|
395
|
-
* `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
|
|
376
|
+
* Run `body` with an owner signature function: the caller's `sign` when it passes one (a
|
|
377
|
+
* `CadreNode` action already inside its approval), otherwise one approval of this service's
|
|
378
|
+
* signer for `action`, the plain-words description a prompting signer shows.
|
|
396
379
|
*/
|
|
397
|
-
|
|
398
|
-
return this.
|
|
380
|
+
async withOwnerSign(action, sign, body) {
|
|
381
|
+
return await withinOwnerApproval(this.requireOwnerSigner(), { partyId: this.config.partyId, action }, sign, body);
|
|
399
382
|
}
|
|
400
383
|
/**
|
|
401
384
|
* Remove a peer from the cadre by owner signature.
|
|
@@ -419,16 +402,14 @@ export class SeedBootstrapService {
|
|
|
419
402
|
* Both digests, the stamp read, the transaction, and the post-commit membership notify
|
|
420
403
|
* come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
|
|
421
404
|
* `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
|
|
422
|
-
* owner-
|
|
405
|
+
* owner-signer precondition and the absent-row gate the notify depends on (below).
|
|
423
406
|
*/
|
|
424
|
-
async removePeer(peerId) {
|
|
425
|
-
// Fail fast on a
|
|
426
|
-
// remove digest, and this precedence (owner
|
|
407
|
+
async removePeer(peerId, sign) {
|
|
408
|
+
// Fail fast on a signerless service BEFORE any DB read: a non-owner cannot sign the
|
|
409
|
+
// remove digest, and this precedence (owner signer, then control DB) is what the
|
|
427
410
|
// unit contract asserts.
|
|
428
|
-
const ownerKey = this.
|
|
429
|
-
|
|
430
|
-
throw new Error('Control database not initialized');
|
|
431
|
-
}
|
|
411
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
412
|
+
const controlDatabase = this.requireControlDatabase();
|
|
432
413
|
// This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
|
|
433
414
|
// internally: deleteCadrePeer's membership notify fires whenever its body resolves,
|
|
434
415
|
// with no idea whether the body wrote anything, so delegating an absent peer would
|
|
@@ -436,74 +417,69 @@ export class SeedBootstrapService {
|
|
|
436
417
|
// NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
|
|
437
418
|
// between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
|
|
438
419
|
// window only — the common "already absent" case is caught here.
|
|
439
|
-
const stampId = await
|
|
420
|
+
const stampId = await controlDatabase.queryCadrePeerStampId(peerId);
|
|
440
421
|
if (stampId === null) {
|
|
441
422
|
log('removePeer: no CadrePeer row for %s (already absent)', peerId);
|
|
442
423
|
return;
|
|
443
424
|
}
|
|
444
425
|
log('Removing peer: %s', peerId);
|
|
445
|
-
await this.controlDatabase.deleteCadrePeer(peerId, ownerKey,
|
|
426
|
+
await this.withOwnerSign(`Remove the machine ${peerId} from the cadre`, sign, (approved) => controlDatabase.deleteCadrePeer(peerId, ownerKey, approved));
|
|
446
427
|
log('Peer %s removed successfully (stamp retired)', peerId);
|
|
447
428
|
}
|
|
448
429
|
/**
|
|
449
|
-
* Seat `ownerPublicKey` as a further owner, signed by this service's owner
|
|
430
|
+
* Seat `ownerPublicKey` as a further owner, signed by this service's owner signer. The
|
|
450
431
|
* liveness check, the stamp, the signature and the membership notify are
|
|
451
|
-
* {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-
|
|
432
|
+
* {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-signer
|
|
452
433
|
* precondition, checked before any read (see {@link removePeer}).
|
|
453
434
|
*
|
|
435
|
+
* @param label - the user's name for the key, bound into the signature
|
|
436
|
+
* ({@link ControlDatabase.insertOwnerKey} has the rule).
|
|
454
437
|
* @returns `true` when the row was seated, `false` when the key is already a live owner.
|
|
455
438
|
*/
|
|
456
|
-
async addOwner(ownerPublicKey) {
|
|
457
|
-
const ownerKey = this.
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
return await this.controlDatabase.insertOwnerKeyVouched(ownerPublicKey, ownerKey,
|
|
439
|
+
async addOwner(ownerPublicKey, label, sign) {
|
|
440
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
441
|
+
const controlDatabase = this.requireControlDatabase();
|
|
442
|
+
// Checked before the approval opens, so a label the row would refuse never prompts the user.
|
|
443
|
+
const storedLabel = ownerKeyLabel(label);
|
|
444
|
+
return await this.withOwnerSign(`Add owner key ${namedOwnerKey(ownerPublicKey, storedLabel)}`, sign, (approved) => controlDatabase.insertOwnerKeyVouched(ownerPublicKey, ownerKey, approved, storedLabel));
|
|
462
445
|
}
|
|
463
446
|
/**
|
|
464
|
-
* Remove `ownerPublicKey` from the owners, signed by this service's owner
|
|
447
|
+
* Remove `ownerPublicKey` from the owners, signed by this service's owner signer: the delete
|
|
465
448
|
* and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
|
|
466
449
|
* the last owner), the digests and the membership notify are
|
|
467
|
-
* {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-
|
|
450
|
+
* {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-signer precondition,
|
|
468
451
|
* checked before any read (see {@link removePeer}).
|
|
469
452
|
*
|
|
470
453
|
* @returns `true` when the row was removed, `false` when the key is not a live owner here.
|
|
471
454
|
*/
|
|
472
|
-
async removeOwner(ownerPublicKey) {
|
|
473
|
-
const ownerKey = this.
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
return await this.controlDatabase.deleteOwnerKey(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
|
|
455
|
+
async removeOwner(ownerPublicKey, sign) {
|
|
456
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
457
|
+
const controlDatabase = this.requireControlDatabase();
|
|
458
|
+
return await this.withOwnerSign(`Remove owner key ${ownerPublicKey}`, sign, (approved) => controlDatabase.deleteOwnerKey(ownerPublicKey, ownerKey, approved));
|
|
478
459
|
}
|
|
479
460
|
/**
|
|
480
|
-
* Re-vouch another owner's row under this service's owner
|
|
461
|
+
* Re-vouch another owner's row under this service's owner signer, so it stands on a remaining
|
|
481
462
|
* owner's signature before the owner that added it is removed
|
|
482
|
-
* ({@link ControlDatabase.revouchOwnerKey}; for `CadreNode.
|
|
483
|
-
* here is the owner-
|
|
463
|
+
* ({@link ControlDatabase.revouchOwnerKey}; for `CadreNode.removeOwner` only). What stays
|
|
464
|
+
* here is the owner-signer precondition, checked before any read (see {@link removePeer}).
|
|
484
465
|
*
|
|
485
466
|
* @returns `true` when the row was re-vouched, `false` when the key is not a live owner here.
|
|
486
467
|
*/
|
|
487
|
-
async revouchOwner(ownerPublicKey) {
|
|
488
|
-
const ownerKey = this.
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
return await this.controlDatabase.revouchOwnerKey(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
|
|
468
|
+
async revouchOwner(ownerPublicKey, sign) {
|
|
469
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
470
|
+
const controlDatabase = this.requireControlDatabase();
|
|
471
|
+
return await this.withOwnerSign(`Re-vouch owner key ${ownerPublicKey}`, sign, (approved) => controlDatabase.revouchOwnerKey(ownerPublicKey, ownerKey, approved));
|
|
493
472
|
}
|
|
494
473
|
/**
|
|
495
|
-
* Remove a machine completely, signed by this service's owner
|
|
496
|
-
* `
|
|
497
|
-
*
|
|
498
|
-
* ({@link
|
|
499
|
-
* checked before any read (see {@link removePeer}).
|
|
474
|
+
* Remove a machine completely, signed by this service's owner signer: its `CadrePeer` row, its
|
|
475
|
+
* `DeviceToken` row, and the withdrawal of every invitation in `target.inviteKeys`, all in one
|
|
476
|
+
* transaction ({@link ControlDatabase.removeMachine}). What stays here is the owner-signer
|
|
477
|
+
* precondition, checked before any read (see {@link removePeer}).
|
|
500
478
|
*/
|
|
501
|
-
async removeMachine(target) {
|
|
502
|
-
const ownerKey = this.
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
}
|
|
506
|
-
return await this.controlDatabase.removeMachine(target, ownerKey, message => this.signMessageBytes(message));
|
|
479
|
+
async removeMachine(target, sign) {
|
|
480
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
481
|
+
const controlDatabase = this.requireControlDatabase();
|
|
482
|
+
return await this.withOwnerSign(`Remove the machine ${target.peerId} from the cadre`, sign, (approved) => controlDatabase.removeMachine(target, ownerKey, approved));
|
|
507
483
|
}
|
|
508
484
|
/**
|
|
509
485
|
* Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
|
|
@@ -514,23 +490,21 @@ export class SeedBootstrapService {
|
|
|
514
490
|
*
|
|
515
491
|
* The stamp read, the voucher signature, the write lock and the membership notify are
|
|
516
492
|
* {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
|
|
517
|
-
* `VouchOwner` to this node's owner key. What stays here is the owner-
|
|
493
|
+
* `VouchOwner` to this node's owner key. What stays here is the owner-signer precondition.
|
|
518
494
|
*
|
|
519
495
|
* A no-op (no throw, no notify) when the row does not exist.
|
|
520
496
|
*
|
|
521
497
|
* @param peerId - the membership row to re-touch.
|
|
522
498
|
* @param updatedAt - the strictly-increasing freshness stamp to write.
|
|
523
|
-
* @throws if no owner
|
|
499
|
+
* @throws if no owner signer is configured (a non-owner cannot
|
|
524
500
|
* re-sign another peer's row) or the control database is not initialized.
|
|
525
501
|
*/
|
|
526
|
-
async reauthorizePeer(peerId, updatedAt) {
|
|
527
|
-
// Fail fast on a
|
|
502
|
+
async reauthorizePeer(peerId, updatedAt, sign) {
|
|
503
|
+
// Fail fast on a signerless service before any DB read (see removePeer): a non-owner
|
|
528
504
|
// cannot re-sign the voucher, and must not silently no-op on an absent row.
|
|
529
|
-
const ownerKey = this.
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
}
|
|
533
|
-
const retouched = await this.controlDatabase.reauthorizeCadrePeer(peerId, updatedAt, ownerKey, message => this.signMessageBytes(message));
|
|
505
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
506
|
+
const controlDatabase = this.requireControlDatabase();
|
|
507
|
+
const retouched = await this.withOwnerSign(`Re-issue the membership of ${peerId}`, sign, (approved) => controlDatabase.reauthorizeCadrePeer(peerId, updatedAt, ownerKey, approved));
|
|
534
508
|
if (!retouched) {
|
|
535
509
|
log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
|
|
536
510
|
return;
|
|
@@ -547,81 +521,115 @@ export class SeedBootstrapService {
|
|
|
547
521
|
*
|
|
548
522
|
* The signatures, the single transaction, and the strictly-monotonic
|
|
549
523
|
* `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
|
|
550
|
-
* stays here is the owner-
|
|
524
|
+
* stays here is the owner-signer precondition.
|
|
551
525
|
*
|
|
552
526
|
* @returns how many tombstones were re-issued (`rows.length` on success).
|
|
553
|
-
* @throws if no owner
|
|
527
|
+
* @throws if no owner signer is configured or the control database is not
|
|
554
528
|
* initialized.
|
|
555
529
|
*/
|
|
556
|
-
async reissueRevocations(rows, reissuedAt) {
|
|
557
|
-
// Fail fast on a
|
|
530
|
+
async reissueRevocations(rows, reissuedAt, sign) {
|
|
531
|
+
// Fail fast on a signerless service before any DB work (see removePeer): a
|
|
558
532
|
// non-owner cannot sign the reissue digests.
|
|
559
|
-
const ownerKey = this.
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
return this.controlDatabase.reissueRevocations(rows, reissuedAt, ownerKey, message => this.signMessageBytes(message));
|
|
533
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
534
|
+
const controlDatabase = this.requireControlDatabase();
|
|
535
|
+
return await this.withOwnerSign(`Re-send ${rows.length} removal record(s) to the cadre`, sign, (approved) => controlDatabase.reissueRevocations(rows, reissuedAt, ownerKey, approved));
|
|
564
536
|
}
|
|
565
537
|
/**
|
|
566
538
|
* Owner filing of the singleton `Revocation` ledger marker, so the table is never a
|
|
567
539
|
* never-written block that the storage layer re-consults on every read.
|
|
568
540
|
*
|
|
569
541
|
* The signature, the insert-if-absent guard and the `'already-open'` mapping are
|
|
570
|
-
* {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-
|
|
542
|
+
* {@link ControlDatabase.openRevocationLedger}'s. What stays here is the owner-signer
|
|
571
543
|
* precondition.
|
|
572
544
|
*
|
|
573
|
-
* @throws if no owner
|
|
545
|
+
* @throws if no owner signer is configured or the control database is not
|
|
574
546
|
* initialized.
|
|
575
547
|
*/
|
|
576
|
-
async openRevocationLedger() {
|
|
577
|
-
// Fail fast on a
|
|
548
|
+
async openRevocationLedger(sign) {
|
|
549
|
+
// Fail fast on a signerless service before any DB work (see removePeer): a
|
|
578
550
|
// non-owner cannot sign the marker's append digest.
|
|
579
|
-
const ownerKey = this.
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
583
|
-
return this.controlDatabase.openRevocationLedger(ownerKey, message => this.signMessageBytes(message));
|
|
551
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
552
|
+
const controlDatabase = this.requireControlDatabase();
|
|
553
|
+
return await this.withOwnerSign('Open the cadre\'s removal ledger', sign, (approved) => controlDatabase.openRevocationLedger(ownerKey, approved));
|
|
584
554
|
}
|
|
585
555
|
/**
|
|
586
556
|
* Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
|
|
587
557
|
* on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
|
|
588
558
|
* expiry and use-count defaults, and the bundle the holder receives are
|
|
589
|
-
* `CadreNode.createCadreInvitation`'s; what stays here is the owner-
|
|
590
|
-
* the
|
|
559
|
+
* `CadreNode.createCadreInvitation`'s; what stays here is the owner-signer precondition and
|
|
560
|
+
* the approval, as {@link insertSelfDeviceToken} keeps them for device tokens.
|
|
591
561
|
*
|
|
592
562
|
* @returns the row as stored, which the invitation bundle carries verbatim.
|
|
593
|
-
* @throws if no owner
|
|
563
|
+
* @throws if no owner signer is configured or the control database is not initialized.
|
|
594
564
|
*/
|
|
595
|
-
async insertCadreInvite(invite) {
|
|
596
|
-
// Fail fast on a
|
|
597
|
-
const ownerKey = this.
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
}
|
|
601
|
-
return await this.controlDatabase.insertCadreInvite(invite, ownerKey, message => this.signMessageBytes(message));
|
|
565
|
+
async insertCadreInvite(invite, sign) {
|
|
566
|
+
// Fail fast on a signerless service before any DB work (see removePeer).
|
|
567
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
568
|
+
const controlDatabase = this.requireControlDatabase();
|
|
569
|
+
return await this.withOwnerSign('Create a machine invitation to the cadre', sign, (approved) => controlDatabase.insertCadreInvite(invite, ownerKey, approved));
|
|
602
570
|
}
|
|
603
571
|
/**
|
|
604
572
|
* Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
|
|
605
573
|
* the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
|
|
606
|
-
* owner-
|
|
574
|
+
* owner-signer precondition.
|
|
607
575
|
*
|
|
608
576
|
* @returns `true` when this call filed the tombstone, `false` when the row is absent here or
|
|
609
577
|
* already withdrawn.
|
|
610
|
-
* @throws if no owner
|
|
578
|
+
* @throws if no owner signer is configured or the control database is not initialized.
|
|
611
579
|
*/
|
|
612
|
-
async withdrawCadreInvite(key) {
|
|
613
|
-
const ownerKey = this.
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
580
|
+
async withdrawCadreInvite(key, sign) {
|
|
581
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
582
|
+
const controlDatabase = this.requireControlDatabase();
|
|
583
|
+
return await this.withOwnerSign(`Withdraw the cadre invitation ${key}`, sign, (approved) => controlDatabase.withdrawCadreInvite(key, ownerKey, approved));
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Owner-signed INSERT of an `OwnerGrant` row — a grant of ownership any member machine can
|
|
587
|
+
* redeem on this owner's behalf ({@link ControlDatabase.insertOwnerGrant}). The keypair mint,
|
|
588
|
+
* the lifetime default and the bundle the holder receives are `CadreNode.createOwnerGrant`'s;
|
|
589
|
+
* what stays here is the owner-signer precondition and the approval, as for
|
|
590
|
+
* {@link insertCadreInvite}. The label is checked before the approval opens, so one the row
|
|
591
|
+
* would refuse never prompts the user.
|
|
592
|
+
*
|
|
593
|
+
* @returns the row as stored, which the grant bundle carries verbatim.
|
|
594
|
+
* @throws if no owner signer is configured, the control database is not initialized, or the label is too long.
|
|
595
|
+
*/
|
|
596
|
+
async insertOwnerGrant(grant, sign) {
|
|
597
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
598
|
+
const controlDatabase = this.requireControlDatabase();
|
|
599
|
+
const label = ownerKeyLabel(grant.label);
|
|
600
|
+
const named = label === null ? 'an owner grant' : `the owner grant "${label}"`;
|
|
601
|
+
return await this.withOwnerSign(`Create ${named}: whoever holds it can become an owner of the cadre`, sign, (approved) => controlDatabase.insertOwnerGrant({ ...grant, label }, ownerKey, approved));
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Owner-signed WITHDRAWAL of an `OwnerGrant`: the `Revocation` tombstone over its stamp, with
|
|
605
|
+
* the row kept ({@link ControlDatabase.withdrawOwnerGrant}). What stays here is the
|
|
606
|
+
* owner-signer precondition.
|
|
607
|
+
*
|
|
608
|
+
* @returns `true` when this call filed the tombstone, `false` when the row is absent here or
|
|
609
|
+
* already withdrawn.
|
|
610
|
+
* @throws if no owner signer is configured or the control database is not initialized.
|
|
611
|
+
*/
|
|
612
|
+
async withdrawOwnerGrant(key, sign) {
|
|
613
|
+
const ownerKey = this.requireOwnerSigner().publicKey;
|
|
614
|
+
const controlDatabase = this.requireControlDatabase();
|
|
615
|
+
return await this.withOwnerSign(`Withdraw the owner grant ${key}`, sign, (approved) => controlDatabase.withdrawOwnerGrant(key, ownerKey, approved));
|
|
618
616
|
}
|
|
619
617
|
/**
|
|
620
618
|
* Create a seed from the current control network state.
|
|
621
|
-
* The seed contains peer information and is signed by an owner
|
|
619
|
+
* The seed contains peer information and is signed by an owner; its `signerKey` is the
|
|
620
|
+
* owner signer's key.
|
|
622
621
|
*/
|
|
623
|
-
async createSeed() {
|
|
624
|
-
|
|
622
|
+
async createSeed(sign) {
|
|
623
|
+
return await this.mintSeed(sign, null);
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* {@link createSeed}, with `selfRelayAddr` added to this node's own peer entry before the
|
|
627
|
+
* seed is signed: the signature covers every address ({@link seedDigest}), so nothing may
|
|
628
|
+
* be added after it.
|
|
629
|
+
*/
|
|
630
|
+
async mintSeed(sign, selfRelayAddr) {
|
|
631
|
+
const signer = this.config.ownerSigner;
|
|
632
|
+
if (!signer) {
|
|
625
633
|
throw new Error('Owner key required to create seeds');
|
|
626
634
|
}
|
|
627
635
|
if (!this.controlDatabase || !this.libp2pNode) {
|
|
@@ -629,18 +637,23 @@ export class SeedBootstrapService {
|
|
|
629
637
|
}
|
|
630
638
|
log('Creating seed for party: %s', this.config.partyId);
|
|
631
639
|
// Query all peers from the control database
|
|
632
|
-
const
|
|
640
|
+
const selfPeerId = this.libp2pNode.peerId.toString();
|
|
641
|
+
const peers = await this.queryPeers(selfPeerId);
|
|
642
|
+
if (selfRelayAddr !== null) {
|
|
643
|
+
addSelfRelayAddr(peers, selfPeerId, selfRelayAddr);
|
|
644
|
+
}
|
|
633
645
|
// Create the seed data (without signature)
|
|
634
646
|
const seedData = {
|
|
635
647
|
partyId: this.config.partyId,
|
|
636
648
|
peers,
|
|
637
649
|
};
|
|
638
650
|
// Sign the seed over its canonical byte representation
|
|
639
|
-
const
|
|
651
|
+
const digestBytes = uint8ArrayFromString(seedDigest(seedData), 'base64url');
|
|
652
|
+
const signature = await this.withOwnerSign('Mint a seed for a machine joining the cadre', sign, (approved) => approved(digestBytes));
|
|
640
653
|
const seed = {
|
|
641
654
|
...seedData,
|
|
642
655
|
signature,
|
|
643
|
-
signerKey:
|
|
656
|
+
signerKey: signer.publicKey,
|
|
644
657
|
};
|
|
645
658
|
log('Created seed with %d peers', peers.length);
|
|
646
659
|
return seed;
|
|
@@ -652,9 +665,8 @@ export class SeedBootstrapService {
|
|
|
652
665
|
* `signerKey` that does NOT come from the seed body: the receiver's
|
|
653
666
|
* node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
|
|
654
667
|
* augmented by pinned keys or TOFU via the configured/overriding
|
|
655
|
-
* `SeedTrustPolicy`. A forged self-
|
|
656
|
-
*
|
|
657
|
-
* by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
|
|
668
|
+
* `SeedTrustPolicy`. A forged self-signed seed does not pass, and neither does
|
|
669
|
+
* one signed by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
|
|
658
670
|
*
|
|
659
671
|
* A key accepted via a pin/TOFU is persisted into the anchor (the policy says
|
|
660
672
|
* so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
|
|
@@ -670,12 +682,12 @@ export class SeedBootstrapService {
|
|
|
670
682
|
if (!merged.success) {
|
|
671
683
|
return merged;
|
|
672
684
|
}
|
|
673
|
-
return { ...merged, ...await this.
|
|
685
|
+
return { ...merged, ...await this.dialPreferredPeers(seed) };
|
|
674
686
|
}
|
|
675
687
|
/**
|
|
676
688
|
* The first half of {@link applySeed}: check the signature and the signer's trust, then merge
|
|
677
689
|
* the seed's peer addresses into the peer store. Every rejection happens here, so this result is
|
|
678
|
-
* what the inbound handler acks with; the
|
|
690
|
+
* what the inbound handler acks with; the dials that follow cannot change it.
|
|
679
691
|
*/
|
|
680
692
|
async verifyAndMergeSeed(seed, options) {
|
|
681
693
|
if (!this.libp2pNode) {
|
|
@@ -709,7 +721,7 @@ export class SeedBootstrapService {
|
|
|
709
721
|
partyId: seed.partyId,
|
|
710
722
|
signerKey: seed.signerKey,
|
|
711
723
|
knownOwnerKeys,
|
|
712
|
-
// Optional-chained like `
|
|
724
|
+
// Optional-chained like `dialPreferredPeers`: partial libp2p handles (unit-test doubles)
|
|
713
725
|
// omit `peerId`. A real node always has one, and a proof bound to a real peer id can
|
|
714
726
|
// only fail against '' — the safe direction.
|
|
715
727
|
localPeerId: this.libp2pNode.peerId?.toString() ?? '',
|
|
@@ -723,54 +735,55 @@ export class SeedBootstrapService {
|
|
|
723
735
|
await this.anchorAcceptedSigner(seed.signerKey, decision);
|
|
724
736
|
const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
|
|
725
737
|
log('Merged seed: %d peers added', peersAdded);
|
|
726
|
-
return { success: true, peersAdded,
|
|
738
|
+
return { success: true, peersAdded, preferredDialsAttempted: 0, preferredDialsFailed: 0 };
|
|
727
739
|
}
|
|
728
740
|
/**
|
|
729
|
-
* The second half of {@link applySeed}: dial the seed's
|
|
741
|
+
* The second half of {@link applySeed}: dial the seed's preferred peers (`SeedPeer.preferred`:
|
|
742
|
+
* the machine that minted it and every always-on machine) to establish connections.
|
|
730
743
|
*
|
|
731
|
-
* Best-effort and COUNTED:
|
|
744
|
+
* Best-effort and COUNTED: a peer that is momentarily down leaves this node seeded but
|
|
732
745
|
* unconnected, which the caller can only see if the outcome is reported (see
|
|
733
|
-
* `ApplySeedResult.
|
|
746
|
+
* `ApplySeedResult.preferredDialsFailed`). Recovery is not this loop's job —
|
|
734
747
|
* `CadreNode.dialColdStartBootstrap` retries these same addresses on every control-cohort
|
|
735
748
|
* reconcile pass until the control database has siblings.
|
|
736
749
|
*
|
|
737
750
|
* The inbound handler runs this AFTER it has acked and closed the stream
|
|
738
|
-
* ({@link handleSeedStream}), because an unreachable
|
|
751
|
+
* ({@link handleSeedStream}), because an unreachable peer can take `dialBudget.totalMs`.
|
|
739
752
|
*/
|
|
740
|
-
async
|
|
753
|
+
async dialPreferredPeers(seed) {
|
|
741
754
|
const node = this.libp2pNode;
|
|
742
755
|
if (!node) {
|
|
743
|
-
log('Seed service shut down before its
|
|
744
|
-
return {
|
|
756
|
+
log('Seed service shut down before its dials; none attempted');
|
|
757
|
+
return { preferredDialsAttempted: 0, preferredDialsFailed: 0 };
|
|
745
758
|
}
|
|
746
|
-
// `createSeed` projects every non-revoked CadrePeer row, so
|
|
747
|
-
// a seed minted after it joined finds ITSELF in the
|
|
748
|
-
//
|
|
759
|
+
// `createSeed` projects every non-revoked CadrePeer row, so a preferred machine applying
|
|
760
|
+
// a seed minted after it joined finds ITSELF in the list. Dialing self always throws,
|
|
761
|
+
// which would report a healthy node as "seeded but stranded".
|
|
749
762
|
// Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
|
|
750
763
|
// and an undefined self simply matches nothing.
|
|
751
|
-
// Every one of
|
|
752
|
-
// (`dialPeerAddrs`), so
|
|
764
|
+
// Every one of a peer's addresses is a candidate, each on its own time limit
|
|
765
|
+
// (`dialPeerAddrs`), so a peer whose first address never answers neither
|
|
753
766
|
// stalls seed application nor goes undialed at its other addresses. The seed's
|
|
754
|
-
// addresses carry no peer id; the dial binds each to the
|
|
767
|
+
// addresses carry no peer id; the dial binds each to the peer's.
|
|
755
768
|
const selfPeerId = node.peerId?.toString();
|
|
756
|
-
let
|
|
757
|
-
let
|
|
758
|
-
for (const peer of seed.peers.filter(p => p.
|
|
769
|
+
let preferredDialsAttempted = 0;
|
|
770
|
+
let preferredDialsFailed = 0;
|
|
771
|
+
for (const peer of seed.peers.filter(p => p.preferred)) {
|
|
759
772
|
if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
|
|
760
773
|
continue;
|
|
761
774
|
}
|
|
762
|
-
|
|
775
|
+
preferredDialsAttempted++;
|
|
763
776
|
try {
|
|
764
777
|
const addrs = parseDialAddrs(peer.multiaddrs);
|
|
765
|
-
log('Dialing
|
|
766
|
-
await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `
|
|
778
|
+
log('Dialing preferred seed peer: %s (%d addr(s))', peer.peerId, addrs.length);
|
|
779
|
+
await dialPeerAddrs(node, peer.peerId, addrs, this.dialBudget, `Seed dial of ${peer.peerId}`);
|
|
767
780
|
}
|
|
768
781
|
catch (error) {
|
|
769
|
-
// Counted even when the
|
|
770
|
-
// not connected to it, and `
|
|
771
|
-
|
|
782
|
+
// Counted even when the peer reaches us only through our own relay: this node is still
|
|
783
|
+
// not connected to it, and `preferredDialsFailed` is how the caller learns that.
|
|
784
|
+
preferredDialsFailed++;
|
|
772
785
|
if (error instanceof SelfRelayOnlyError) {
|
|
773
|
-
log('
|
|
786
|
+
log('Seed peer %s is reachable only by relaying through this node; waiting for it to reconnect', peer.peerId);
|
|
774
787
|
}
|
|
775
788
|
else {
|
|
776
789
|
log('Failed to dial peer %s: %o', peer.peerId, error);
|
|
@@ -778,8 +791,8 @@ export class SeedBootstrapService {
|
|
|
778
791
|
// Continue - not all peers need to be reachable
|
|
779
792
|
}
|
|
780
793
|
}
|
|
781
|
-
log('Dialed seed
|
|
782
|
-
return {
|
|
794
|
+
log('Dialed preferred seed peers: %d/%d dial(s) failed', preferredDialsFailed, preferredDialsAttempted);
|
|
795
|
+
return { preferredDialsAttempted, preferredDialsFailed };
|
|
783
796
|
}
|
|
784
797
|
/**
|
|
785
798
|
* Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
|
|
@@ -883,7 +896,7 @@ export class SeedBootstrapService {
|
|
|
883
896
|
}
|
|
884
897
|
const peerId = peerIdFromString(target.peerId);
|
|
885
898
|
const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
|
|
886
|
-
await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs,
|
|
899
|
+
await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, preferred: false }]);
|
|
887
900
|
if (addrs.length === 0) {
|
|
888
901
|
return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
|
|
889
902
|
}
|
|
@@ -984,16 +997,16 @@ export class SeedBootstrapService {
|
|
|
984
997
|
}
|
|
985
998
|
}
|
|
986
999
|
/**
|
|
987
|
-
* The peers a seed carries: the control database's own projection
|
|
1000
|
+
* The peers a seed `minterPeerId` mints carries: the control database's own projection
|
|
988
1001
|
* ({@link ControlDatabase.querySeedPeers}, shared with the cadre invitation redemption
|
|
989
|
-
* reply), which reads through the revocation filter and
|
|
990
|
-
*
|
|
1002
|
+
* reply), which reads through the revocation filter and marks the minter and every
|
|
1003
|
+
* always-on machine preferred — a dial hint, not a trust decision.
|
|
991
1004
|
*/
|
|
992
|
-
async queryPeers() {
|
|
1005
|
+
async queryPeers(minterPeerId) {
|
|
993
1006
|
if (!this.controlDatabase) {
|
|
994
1007
|
return [];
|
|
995
1008
|
}
|
|
996
|
-
return await this.controlDatabase.querySeedPeers();
|
|
1009
|
+
return await this.controlDatabase.querySeedPeers(minterPeerId);
|
|
997
1010
|
}
|
|
998
1011
|
/**
|
|
999
1012
|
* Register the seed protocol handler. The inbound closure just delegates to
|
|
@@ -1009,14 +1022,14 @@ export class SeedBootstrapService {
|
|
|
1009
1022
|
}
|
|
1010
1023
|
/**
|
|
1011
1024
|
* Read one inbound seed frame, verify and merge it, write the ack and close the
|
|
1012
|
-
* stream, then dial the seed's
|
|
1025
|
+
* stream, then dial the seed's preferred peers.
|
|
1013
1026
|
*
|
|
1014
|
-
* The ack and the close come BEFORE
|
|
1027
|
+
* The ack and the close come BEFORE those dials: an unreachable peer can hold
|
|
1015
1028
|
* a dial for `dialBudget.totalMs` (86 s at the default declared link), which is not
|
|
1016
1029
|
* link time and does not belong inside the sender's delivery deadline. The close is
|
|
1017
1030
|
* what releases the sender, which reads the ack to end-of-stream. The stream stays
|
|
1018
1031
|
* counted in {@link activeStreams} through the dials, so {@link maxConcurrentSeeds}
|
|
1019
|
-
* still bounds concurrent
|
|
1032
|
+
* still bounds concurrent dial phases.
|
|
1020
1033
|
*
|
|
1021
1034
|
* Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
|
|
1022
1035
|
* {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
|
|
@@ -1045,7 +1058,7 @@ export class SeedBootstrapService {
|
|
|
1045
1058
|
acked = true;
|
|
1046
1059
|
await replyAndClose(stream, { accepted: merged.success, reason: merged.error, code: merged.code }, 'Seed');
|
|
1047
1060
|
if (merged.success) {
|
|
1048
|
-
await this.
|
|
1061
|
+
await this.dialPreferredPeers(seed);
|
|
1049
1062
|
this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
|
|
1050
1063
|
}
|
|
1051
1064
|
else {
|
|
@@ -1117,16 +1130,20 @@ export class SeedBootstrapService {
|
|
|
1117
1130
|
* does not listen. Call `CadreNode.reconcileControlCohort()` after delivering
|
|
1118
1131
|
* the seed to dial at once.
|
|
1119
1132
|
*
|
|
1133
|
+
* The vouch and the seed are one owner approval.
|
|
1134
|
+
*
|
|
1120
1135
|
* @param options - Drone peer info from provider API
|
|
1121
1136
|
* @returns Seed and encoded seed for drone initialization
|
|
1122
1137
|
*/
|
|
1123
|
-
async addDrone(options) {
|
|
1138
|
+
async addDrone(options, sign) {
|
|
1124
1139
|
const { dronePeerId, droneMultiaddrs } = options;
|
|
1125
1140
|
log('Adding drone: %s', dronePeerId);
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1141
|
+
const seed = await this.withOwnerSign(`Add the machine ${dronePeerId} to the cadre`, sign, async (approved) => {
|
|
1142
|
+
// 1. Authorize the new drone peer
|
|
1143
|
+
await this.authorizePeer({ peerId: dronePeerId, multiaddrs: droneMultiaddrs }, approved);
|
|
1144
|
+
// 2. Create seed with current state
|
|
1145
|
+
return await this.createSeed(approved);
|
|
1146
|
+
});
|
|
1130
1147
|
// 3. Encode for transport
|
|
1131
1148
|
const encodedSeed = this.encodeSeed(seed);
|
|
1132
1149
|
log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
|
|
@@ -1139,25 +1156,20 @@ export class SeedBootstrapService {
|
|
|
1139
1156
|
* 1. Authorizes the new phone peer
|
|
1140
1157
|
* 2. Creates a seed with relay addresses for dialing
|
|
1141
1158
|
*
|
|
1159
|
+
* The vouch and the seed are one owner approval.
|
|
1160
|
+
*
|
|
1142
1161
|
* @param phonePeerId - Peer ID of the new phone
|
|
1143
1162
|
* @returns Seed with relay addresses for out-of-band delivery
|
|
1144
1163
|
*/
|
|
1145
|
-
async addPhoneWithRelay(phonePeerId) {
|
|
1164
|
+
async addPhoneWithRelay(phonePeerId, sign) {
|
|
1146
1165
|
log('Adding phone with relay: %s', phonePeerId);
|
|
1147
|
-
// 1. Authorize the new phone peer (no multiaddrs - NAT'd)
|
|
1148
|
-
await this.authorizePeer({ peerId: phonePeerId });
|
|
1149
|
-
// 2. Get relay address for this node
|
|
1150
1166
|
const relayAddr = await this.getRelayAddress();
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
if (ourPeer && !ourPeer.multiaddrs.includes(relayAddr)) {
|
|
1158
|
-
ourPeer.multiaddrs.push(relayAddr);
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1167
|
+
const seed = await this.withOwnerSign(`Add the phone ${phonePeerId} to the cadre`, sign, async (approved) => {
|
|
1168
|
+
// 1. Authorize the new phone peer (no multiaddrs - NAT'd)
|
|
1169
|
+
await this.authorizePeer({ peerId: phonePeerId }, approved);
|
|
1170
|
+
// 2. Create seed - with our relay address in our own entry, when we have one
|
|
1171
|
+
return await this.mintSeed(approved, relayAddr);
|
|
1172
|
+
});
|
|
1161
1173
|
const encodedSeed = this.encodeSeed(seed);
|
|
1162
1174
|
log('Phone %s added with relay, seed created', phonePeerId);
|
|
1163
1175
|
return { seed, encodedSeed };
|