@serfab/cadre-core 1.13.0 → 1.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cadre-invite-protocol.d.ts +348 -0
- package/dist/cadre-invite-protocol.js +605 -0
- package/dist/cadre-invite-protocol.js.map +1 -0
- package/dist/cadre-node.d.ts +523 -180
- package/dist/cadre-node.js +921 -293
- package/dist/cadre-node.js.map +1 -1
- package/dist/claim-proof.d.ts +45 -0
- package/dist/claim-proof.js +132 -0
- package/dist/claim-proof.js.map +1 -0
- package/dist/control-authorization.d.ts +80 -19
- package/dist/control-authorization.js +55 -13
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +514 -97
- package/dist/control-database.js +1033 -195
- package/dist/control-database.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +142 -0
- package/dist/control-protocol-guard.js +293 -0
- package/dist/control-protocol-guard.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +801 -1067
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +4 -0
- package/dist/control-stream.js +22 -7
- package/dist/control-stream.js.map +1 -1
- package/dist/delegate-admission.d.ts +1 -1
- package/dist/delegate-admission.js +1 -1
- package/dist/device-token.d.ts +11 -9
- package/dist/device-token.js +14 -12
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +15 -0
- package/dist/ed25519-key.js +28 -1
- package/dist/ed25519-key.js.map +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.js +27 -9
- package/dist/index.js.map +1 -1
- package/dist/link-budget.d.ts +16 -29
- package/dist/link-budget.js +16 -31
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +190 -151
- package/dist/membership-connection-gater.js +235 -173
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +19 -0
- package/dist/node-claim-payload.js +120 -0
- package/dist/node-claim-payload.js.map +1 -0
- package/dist/owner-anchor-sync.d.ts +22 -0
- package/dist/owner-anchor-sync.js +143 -0
- package/dist/owner-anchor-sync.js.map +1 -0
- package/dist/peer-authorization.d.ts +113 -18
- package/dist/peer-authorization.js +225 -29
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-dial.d.ts +19 -0
- package/dist/peer-dial.js +22 -1
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +26 -2
- package/dist/peer-join-backfill.js +54 -12
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +13 -9
- package/dist/peer-record.js +16 -12
- package/dist/peer-record.js.map +1 -1
- package/dist/pending-join-runner.d.ts +66 -58
- package/dist/pending-join-runner.js +136 -145
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/push-fanout.d.ts +8 -2
- package/dist/push-fanout.js +8 -2
- package/dist/push-fanout.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +147 -122
- package/dist/seed-bootstrap.js +284 -269
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +98 -37
- package/dist/seed-trust-policy.js +188 -2
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +24 -26
- package/dist/strand-addr-protocol.js +19 -26
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-formation-protocol.js +1 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-revocation-enforcer.d.ts +11 -11
- package/dist/strand-revocation-enforcer.js +11 -11
- package/dist/strand-wake-protocol.d.ts +13 -16
- package/dist/strand-wake-protocol.js +13 -20
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/trusted-owner-store.d.ts +35 -5
- package/dist/trusted-owner-store.js +50 -8
- package/dist/trusted-owner-store.js.map +1 -1
- package/dist/types.d.ts +302 -87
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- package/src/cadre-invite-protocol.ts +864 -0
- package/src/cadre-node.ts +9381 -8686
- package/src/claim-proof.ts +147 -0
- package/src/control-authorization.ts +203 -117
- package/src/control-cohort.ts +1 -1
- package/src/control-database.ts +1216 -218
- package/src/control-protocol-guard.ts +348 -0
- package/src/control-schema.ts +812 -1078
- package/src/control-stream.ts +26 -7
- package/src/delegate-admission.ts +1 -1
- package/src/device-token.ts +14 -10
- package/src/ed25519-key.ts +28 -1
- package/src/index.ts +84 -10
- package/src/link-budget.ts +16 -32
- package/src/membership-connection-gater.ts +637 -570
- package/src/node-claim-payload.ts +125 -0
- package/src/owner-anchor-sync.ts +171 -0
- package/src/peer-authorization.ts +258 -29
- package/src/peer-dial.ts +22 -1
- package/src/peer-join-backfill.ts +56 -11
- package/src/peer-record.ts +16 -11
- package/src/pending-join-runner.ts +729 -735
- package/src/push-fanout.ts +8 -2
- package/src/seed-bootstrap.ts +344 -321
- package/src/seed-trust-policy.ts +353 -140
- package/src/strand-addr-protocol.ts +26 -35
- package/src/strand-formation-protocol.ts +1 -1
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +13 -23
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2471 -2242
package/dist/cadre-node.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createLibp2pNode } from '@optimystic/db-p2p';
|
|
|
6
6
|
import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
|
|
7
7
|
import { multiaddr } from '@multiformats/multiaddr';
|
|
8
8
|
import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
|
|
9
|
-
import { sign } from '@optimystic/quereus-plugin-crypto';
|
|
9
|
+
import { generatePrivateKey, sign } from '@optimystic/quereus-plugin-crypto';
|
|
10
10
|
import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
11
11
|
import { strandTransportKey } from './strand-transport-key.js';
|
|
12
12
|
import { assertScopeKeyCharset, assertStrandScopeKey, controlStorageScope, InvalidStrandIdError, isValidStrandScopeKey } from './storage-scope.js';
|
|
@@ -22,8 +22,9 @@ import { MemoryEnrolledMachineStore } from './enrolled-machine-store.js';
|
|
|
22
22
|
import { JoinedStrandSession, KeyStoreJoinedStrandStore, MemoryJoinedStrandStore } from './joined-strand-store.js';
|
|
23
23
|
import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
|
|
24
24
|
import { strandFretPeerAddrs } from './strand-fret-addrs.js';
|
|
25
|
-
import { verifyCadrePeerVoucher } from './peer-authorization.js';
|
|
26
|
-
import {
|
|
25
|
+
import { verifyCadrePeerVoucher, verifyInvitationAdmission } from './peer-authorization.js';
|
|
26
|
+
import { deriveOwnerAnchor } from './owner-anchor-sync.js';
|
|
27
|
+
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
27
28
|
import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, trailingPeerId, withTrailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
|
|
28
29
|
import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
|
|
29
30
|
import { StrandWatcher } from './strand-watcher.js';
|
|
@@ -32,17 +33,21 @@ import { PeerJoinBackfill } from './peer-join-backfill.js';
|
|
|
32
33
|
import { deriveCohortMembers } from './strand-cohort.js';
|
|
33
34
|
import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
|
|
34
35
|
import { dialPeerAddrs, SelfRelayOnlyError, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
|
|
35
|
-
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget,
|
|
36
|
+
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, relayedRequestBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
36
37
|
import { EnrollmentService } from './enrollment.js';
|
|
37
38
|
import { HibernationManager } from './hibernation-manager.js';
|
|
38
|
-
import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId } from './control-database.js';
|
|
39
|
+
import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, pendingJoinId } from './control-database.js';
|
|
40
|
+
import { CadreInviteHandler, encodeCadreInvitation, redeemAtMembers, signRedeemRequest } from './cadre-invite-protocol.js';
|
|
39
41
|
import { FormationPostApprovalError } from './strand-formation-rejection.js';
|
|
40
42
|
import { PendingJoinRunner, membershipInvitesToStage, parseStoredDisclosure, requestedPendingJoin } from './pending-join-runner.js';
|
|
41
|
-
import { SeedBootstrapService } from './seed-bootstrap.js';
|
|
43
|
+
import { SeedBootstrapService, mergeSeedPeers, seedDigest } from './seed-bootstrap.js';
|
|
44
|
+
import { claimSecretTrustPolicy } from './seed-trust-policy.js';
|
|
45
|
+
import { ClaimRefusedError, claimProof, parseClaimSecret } from './claim-proof.js';
|
|
42
46
|
import { StrandSolicitationService } from './strand-solicitation.js';
|
|
43
47
|
import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
|
|
44
48
|
import { selectInvitationSiblingAddrs } from './invitation-bootstrap.js';
|
|
45
|
-
import { createMembershipConnectionGater,
|
|
49
|
+
import { createMembershipConnectionGater, UnauthorizedReservationBudget } from './membership-connection-gater.js';
|
|
50
|
+
import { controlNetworkName, installControlProtocolGuard } from './control-protocol-guard.js';
|
|
46
51
|
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
47
52
|
import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
|
|
48
53
|
import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, prunePeerStrandKeys, peerStrandKey } from './delegate-admission.js';
|
|
@@ -77,6 +82,15 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
|
|
|
77
82
|
* minute rather than one a tick.
|
|
78
83
|
*/
|
|
79
84
|
export const STRAND_PEER_ADDR_RETRY_MS = 60 * 1000;
|
|
85
|
+
/**
|
|
86
|
+
* Default lifetime of an UNTARGETED, OWNER-GRANTING cadre invitation
|
|
87
|
+
* ({@link CadreNode.createCadreInvitation}): whoever holds the bundle becomes an owner, so
|
|
88
|
+
* it is a bearer credential for admin rights and lives only long enough to be pasted or
|
|
89
|
+
* scanned by the device it was minted for.
|
|
90
|
+
*/
|
|
91
|
+
export const OWNER_INVITATION_DEFAULT_TTL_MS = 15 * 60 * 1000;
|
|
92
|
+
/** Default lifetime of every other cadre invitation: targeted, or granting membership only. */
|
|
93
|
+
export const CADRE_INVITATION_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
80
94
|
/**
|
|
81
95
|
* A circuit relay as a strand-addr RPC target: dialed by peerId, with its
|
|
82
96
|
* direct addr riding along as the fallback for a relay we hold no connection to.
|
|
@@ -116,11 +130,10 @@ function unionAddrs(primary, extra) {
|
|
|
116
130
|
}
|
|
117
131
|
return out;
|
|
118
132
|
}
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
*/
|
|
133
|
+
/** Whether a `CadrePeer` or `OwnerKey` row's proof is an invitation admission rather than an owner voucher (`types.ts` → `CadrePeerRow`, `OwnerKeyRow`). */
|
|
134
|
+
function isInvitationAdmitted(row) {
|
|
135
|
+
return row.vouchSig === null && row.vouchUsage !== null;
|
|
136
|
+
}
|
|
124
137
|
function signMessageWith(privateKeyB64) {
|
|
125
138
|
return (message) => sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url');
|
|
126
139
|
}
|
|
@@ -244,8 +257,22 @@ export class CadreNode {
|
|
|
244
257
|
* start() cycle rebuilds it with a fresh caught-up-peer memo.
|
|
245
258
|
*/
|
|
246
259
|
this.controlBackfill = null;
|
|
260
|
+
/**
|
|
261
|
+
* Devices whose invitation redemption this member has verified and is admitting right now,
|
|
262
|
+
* each with its count of overlapping redemptions. The control backfill's gate admits them
|
|
263
|
+
* as it admits a member, for that window only ({@link catchUpRedeemingDevice}).
|
|
264
|
+
*/
|
|
265
|
+
this.admittingDevices = new Map();
|
|
247
266
|
this.strandWatcher = null;
|
|
248
267
|
this.seedBootstrapService = null;
|
|
268
|
+
/**
|
|
269
|
+
* The claim-secret trust policy of a node started with `config.claim`, built once by
|
|
270
|
+
* {@link initializeClaimPolicy} over {@link trustedOwnerStore} and handed to every
|
|
271
|
+
* seed service this node constructs ({@link inboundSeedTrustPolicy}). Kept across
|
|
272
|
+
* stop()→start() like the anchor it writes: its in-process latch and failure window
|
|
273
|
+
* are per node, not per seed service. Null when the node has no claim secret.
|
|
274
|
+
*/
|
|
275
|
+
this.claimTrustPolicy = null;
|
|
249
276
|
this.strandSolicitationService = null;
|
|
250
277
|
/**
|
|
251
278
|
* The service whose formation handler is registered on the control node. Trails
|
|
@@ -264,6 +291,13 @@ export class CadreNode {
|
|
|
264
291
|
* stops conflating control addresses with strand seeding.
|
|
265
292
|
*/
|
|
266
293
|
this.strandAddrService = null;
|
|
294
|
+
/**
|
|
295
|
+
* Cadre invitation redemption handler (`/sereus/cadre-invite/1.0.0`): a device holding an
|
|
296
|
+
* invitation dials this machine and proves possession; the handler seats the row and
|
|
297
|
+
* admits the device against this node's control database. Registered by {@link start}
|
|
298
|
+
* once that database is up, on every node.
|
|
299
|
+
*/
|
|
300
|
+
this.cadreInviteHandler = null;
|
|
267
301
|
/**
|
|
268
302
|
* Server-side push-wake fan-out. Constructed by {@link start} only when
|
|
269
303
|
* `config.push` (an injected `PushNotifier` + policy) is present — without it
|
|
@@ -359,24 +393,10 @@ export class CadreNode {
|
|
|
359
393
|
/**
|
|
360
394
|
* Most-recently pushed invite addresses (see {@link setInviteAddresses}).
|
|
361
395
|
* When non-null these take priority over `libp2pNode.getMultiaddrs()` when
|
|
362
|
-
* minting
|
|
363
|
-
* control-network node never needs to dial back to the manager.
|
|
396
|
+
* minting cadre invitations — the host pushes NAT-resolved addresses here so
|
|
397
|
+
* the control-network node never needs to dial back to the manager.
|
|
364
398
|
*/
|
|
365
399
|
this.latestInviteAddresses = null;
|
|
366
|
-
/**
|
|
367
|
-
* Epoch ms until which the control-network inbound gate admits
|
|
368
|
-
* not-yet-authorized peers (see {@link admitInboundControlConnection} /
|
|
369
|
-
* {@link openEnrollmentWindow}). 0 = no window open. Opened automatically by
|
|
370
|
-
* {@link createInvite}; deliberately NOT reset on stop() — a stop/start cycle
|
|
371
|
-
* inside an outstanding invite's validity must not strand the invitee.
|
|
372
|
-
*
|
|
373
|
-
* NOTE: in-memory only, so a PROCESS restart mid-invite closes the door until
|
|
374
|
-
* the owner re-mints or the invitee is authorized. Fine while invites are
|
|
375
|
-
* short-lived and redeemed promptly; if long-lived invites or restart-prone
|
|
376
|
-
* hosts become normal, persist the window (or derive it from the issued-invite
|
|
377
|
-
* records `SeedBootstrapService` already keeps).
|
|
378
|
-
*/
|
|
379
|
-
this.enrollmentWindowUntil = 0;
|
|
380
400
|
/**
|
|
381
401
|
* Lazily-parsed PeerIds of `controlNetwork.bootstrapNodes` (see
|
|
382
402
|
* {@link getBootstrapPeerIds}) — infrastructure the inbound gate always admits.
|
|
@@ -525,7 +545,7 @@ export class CadreNode {
|
|
|
525
545
|
this.pendingMembershipInvites = new Map();
|
|
526
546
|
/**
|
|
527
547
|
* Invite keys of the membership invitations this process has staged, by
|
|
528
|
-
* {@link adoptFormationMembershipInvite} or from a `
|
|
548
|
+
* {@link adoptFormationMembershipInvite} or from a `JoinSuccess` row
|
|
529
549
|
* ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
|
|
530
550
|
* staged again by the next pending-join pass. Same lifetime as
|
|
531
551
|
* {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
|
|
@@ -870,11 +890,19 @@ export class CadreNode {
|
|
|
870
890
|
// libp2p/network bring-up, so a misconfiguration or an access-denied secure
|
|
871
891
|
// store fails closed before a node is created.
|
|
872
892
|
await this.resolveIdentityKey();
|
|
893
|
+
// A conflicting `claim` is refused BEFORE the anchor is built: the store below
|
|
894
|
+
// writes the config pins, and on a persistent anchor a pin written ahead of
|
|
895
|
+
// this refusal would survive the failed start and mark the node claimed by it.
|
|
896
|
+
this.validateClaimConfig();
|
|
873
897
|
// Bring up the node-local trusted-owner anchor (and seed config pins)
|
|
874
898
|
// before any network bring-up: a mis-scoped injected store fails closed
|
|
875
899
|
// here, and out-of-band pins are anchored before the first seed/peer
|
|
876
900
|
// interaction could consult them.
|
|
877
901
|
await this.initializeTrustedOwnerStore();
|
|
902
|
+
// A node started with a claim secret gets its trust policy here, over the anchor
|
|
903
|
+
// just built; a malformed secret fails closed before any network bring-up, like
|
|
904
|
+
// a mis-scoped anchor above.
|
|
905
|
+
this.initializeClaimPolicy();
|
|
878
906
|
// Bring up the node-local cold-start bootstrap-peer store alongside the
|
|
879
907
|
// anchor, and for the same two reasons: a mis-scoped injected store fails
|
|
880
908
|
// closed before any network bring-up, and the retained dial targets are
|
|
@@ -915,6 +943,9 @@ export class CadreNode {
|
|
|
915
943
|
this.controlNode = await this.createControlNode();
|
|
916
944
|
timing('[start] createControlNode: %dms', Math.round(performance.now() - t0));
|
|
917
945
|
log('Control node started with ID: %s', this.controlNode.peerId.toString());
|
|
946
|
+
// Before anything else can happen on the node: the quiet period above holds every
|
|
947
|
+
// connection off, so no stream can arrive ahead of the guard.
|
|
948
|
+
this.installControlProtocolGuard(this.controlNode);
|
|
918
949
|
// Extract coordinatedRepo from the node (attached by createLibp2pNode)
|
|
919
950
|
const coordinatedRepo = this.controlNode.coordinatedRepo;
|
|
920
951
|
if (!coordinatedRepo) {
|
|
@@ -976,25 +1007,23 @@ export class CadreNode {
|
|
|
976
1007
|
// Start hibernation manager
|
|
977
1008
|
this.hibernationManager.start();
|
|
978
1009
|
// Register the control-network push-wake receiver: a same-cadre peer can
|
|
979
|
-
// signal us to bring a hibernating strand online.
|
|
980
|
-
//
|
|
981
|
-
// path as a local wake.
|
|
1010
|
+
// signal us to bring a hibernating strand online. The protocol guard admits
|
|
1011
|
+
// only AUTHORIZED members (not the addressable surface); the wake routes
|
|
1012
|
+
// through the same path as a local wake.
|
|
982
1013
|
this.strandWakeService = new StrandWakeService({
|
|
983
|
-
isMember: (peerId) => this.isAuthorizedMember(peerId),
|
|
984
1014
|
getStrand: (strandId) => this.strandManager.getInstance(strandId),
|
|
985
1015
|
wake: (strandId) => this.wakeStrand(strandId),
|
|
986
1016
|
});
|
|
987
1017
|
await this.strandWakeService.initialize(this.controlNode);
|
|
988
1018
|
// Register the control-network strand-address responder: a same-cadre peer
|
|
989
1019
|
// resolving a strand's bootstrap seed asks us for our live strand-network
|
|
990
|
-
// multiaddrs (its CadrePeer row only knows our *control* address).
|
|
991
|
-
// AUTHORIZED
|
|
992
|
-
// The same RPC doubles as the delegate-announce channel:
|
|
993
|
-
// may carry the derived peerId its strand node runs as, and
|
|
994
|
-
// admission grant so our connection gate (and thus our relay
|
|
995
|
-
// enabled) admits it — see delegate-admission.ts.
|
|
1020
|
+
// multiaddrs (its CadrePeer row only knows our *control* address). The
|
|
1021
|
+
// protocol guard admits only AUTHORIZED members; answers only for strands we
|
|
1022
|
+
// are actively meshing. The same RPC doubles as the delegate-announce channel:
|
|
1023
|
+
// a member's request may carry the derived peerId its strand node runs as, and
|
|
1024
|
+
// we record an admission grant so our connection gate (and thus our relay
|
|
1025
|
+
// server, when enabled) admits it — see delegate-admission.ts.
|
|
996
1026
|
this.strandAddrService = new StrandAddrService({
|
|
997
|
-
isMember: (peerId) => this.isAuthorizedMember(peerId),
|
|
998
1027
|
getStrandMultiaddrs: (strandId) => this.getStrandMultiaddrs(strandId),
|
|
999
1028
|
onDelegateAnnounce: (announcer, strandId, delegate) => this.grantDelegateAdmission(announcer, strandId, delegate),
|
|
1000
1029
|
});
|
|
@@ -1004,6 +1033,22 @@ export class CadreNode {
|
|
|
1004
1033
|
// check a token for an inviter that is offline. After the control database is up,
|
|
1005
1034
|
// so the responder never reads a database that cannot answer yet.
|
|
1006
1035
|
await this.installDefaultFormationResponder();
|
|
1036
|
+
// Answer cadre invitation redemptions from now on: a device holding an invitation
|
|
1037
|
+
// dials any member machine, this one included, and proves possession in-protocol.
|
|
1038
|
+
// After the control database is up, since the handler seats and redeems against it.
|
|
1039
|
+
this.cadreInviteHandler = new CadreInviteHandler({
|
|
1040
|
+
partyId: this.config.controlNetwork.partyId,
|
|
1041
|
+
store: this.controlDatabase,
|
|
1042
|
+
catchUpDevice: (peerId) => this.catchUpRedeemingDevice(peerId),
|
|
1043
|
+
});
|
|
1044
|
+
await this.cadreInviteHandler.register(this.controlNode);
|
|
1045
|
+
// A node waiting to be claimed listens for its claim seed from the start: nobody
|
|
1046
|
+
// else will turn the listener on, and a node no seed can reach cannot be claimed.
|
|
1047
|
+
// Before `control:connected`, so an embedder that reports "awaiting claim" on that
|
|
1048
|
+
// event is already reachable on `/sereus/seed/1.0.0`.
|
|
1049
|
+
if (this.config.claim) {
|
|
1050
|
+
await this.enableSeedListener();
|
|
1051
|
+
}
|
|
1007
1052
|
// Server-side push-wake fan-out: only when push is configured.
|
|
1008
1053
|
if (this.config.push) {
|
|
1009
1054
|
this.pushFanoutService = this.buildPushFanout(this.config.push);
|
|
@@ -1158,15 +1203,22 @@ export class CadreNode {
|
|
|
1158
1203
|
}
|
|
1159
1204
|
/**
|
|
1160
1205
|
* Construct (or adopt) the node-local trusted-owner anchor and seed the
|
|
1161
|
-
* out-of-band pinned keys from `config.trustedOwners`. The
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1206
|
+
* out-of-band pinned keys from `config.trustedOwners`. The anchor's BASE comes
|
|
1207
|
+
* only from out of band — genesis self-trust ({@link initializeSeedBootstrap}),
|
|
1208
|
+
* config pins (here), runtime enrollment pins ({@link trustOwnerKeys}) and a
|
|
1209
|
+
* claim; everything else in it is derived from that base along verifiable
|
|
1210
|
+
* chains through the replicated `OwnerKey` table ({@link syncOwnerAnchor}),
|
|
1211
|
+
* never read from the table directly.
|
|
1165
1212
|
*
|
|
1166
1213
|
* Idempotent across stop()→start(): the store instance is kept, and
|
|
1167
1214
|
* re-seeding config pins is a no-op ({@link TrustedOwnerStore.trust} is
|
|
1168
1215
|
* idempotent). An injected store scoped to a different party is a
|
|
1169
1216
|
* configuration error (fail closed before any network bring-up).
|
|
1217
|
+
*
|
|
1218
|
+
* NOTE: a config pin of a key the owners have since REMOVED is re-anchored here at
|
|
1219
|
+
* every start and removed again by the first sync (`refreshMembershipGate('start')`,
|
|
1220
|
+
* fired without await from {@link start}), so such a pin is honoured for at most one
|
|
1221
|
+
* membership refresh per start; dropping the pin from the config ends that.
|
|
1170
1222
|
*/
|
|
1171
1223
|
async initializeTrustedOwnerStore() {
|
|
1172
1224
|
const { trustedOwners } = this.config;
|
|
@@ -1187,6 +1239,76 @@ export class CadreNode {
|
|
|
1187
1239
|
await this.trustedOwnerStore.trust(key, trustedOwners?.pinnedSource ?? 'operator');
|
|
1188
1240
|
}
|
|
1189
1241
|
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Refuse a `config.claim` that sits beside another cold-start trust source. A node
|
|
1244
|
+
* has exactly ONE, so `claim` with a non-empty `trustedOwners.pinnedKeys` or a
|
|
1245
|
+
* `seedTrustPolicy` is refused here rather than silently letting one of them win.
|
|
1246
|
+
* Asserted at this seam, not by a type: the embedders build `CadreNodeConfig` as one
|
|
1247
|
+
* object literal with every optional field present, which a discriminated union over
|
|
1248
|
+
* the whole config would not express.
|
|
1249
|
+
*
|
|
1250
|
+
* Runs BEFORE {@link initializeTrustedOwnerStore}, which writes the config pins into
|
|
1251
|
+
* the anchor: on a persistent anchor a pin written ahead of this refusal would outlive
|
|
1252
|
+
* the failed start, and the node, restarted with the pin dropped from its config and
|
|
1253
|
+
* `claim` kept, would read as claimed by that pin and never accept its claimant.
|
|
1254
|
+
*/
|
|
1255
|
+
validateClaimConfig() {
|
|
1256
|
+
if (!this.config.claim) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if ((this.config.trustedOwners?.pinnedKeys?.length ?? 0) > 0) {
|
|
1260
|
+
throw new Error('CadreNodeConfig: `claim` and `trustedOwners.pinnedKeys` are both set — a node has one ' +
|
|
1261
|
+
'cold-start trust source; a node waiting to be claimed anchors its claimant, not a pin');
|
|
1262
|
+
}
|
|
1263
|
+
if (this.config.seedTrustPolicy) {
|
|
1264
|
+
throw new Error('CadreNodeConfig: `claim` and `seedTrustPolicy` are both set — a node has one cold-start ' +
|
|
1265
|
+
'trust source; the claim secret supplies the seed trust policy itself');
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* Build {@link claimTrustPolicy} from `config.claim`, once per node (a restart keeps
|
|
1270
|
+
* the policy, as {@link initializeTrustedOwnerStore} keeps the anchor it writes).
|
|
1271
|
+
* Runs after the anchor exists and before any network bring-up, so a malformed secret
|
|
1272
|
+
* fails `start()` closed with nothing to tear down; `parseClaimSecret` names the
|
|
1273
|
+
* problem and never echoes the value. The conflicts with the other trust sources are
|
|
1274
|
+
* {@link validateClaimConfig}'s, checked before the anchor is written.
|
|
1275
|
+
*/
|
|
1276
|
+
initializeClaimPolicy() {
|
|
1277
|
+
const { claim } = this.config;
|
|
1278
|
+
if (!claim || this.claimTrustPolicy) {
|
|
1279
|
+
return;
|
|
1280
|
+
}
|
|
1281
|
+
// Unreachable in practice: start() builds the store just above. Guarded so the
|
|
1282
|
+
// policy can never be built over a missing anchor and silently claim into nothing.
|
|
1283
|
+
if (!this.trustedOwnerStore) {
|
|
1284
|
+
throw new Error('CadreNode: the trusted-owner anchor must exist before the claim policy is built');
|
|
1285
|
+
}
|
|
1286
|
+
this.claimTrustPolicy = claimSecretTrustPolicy({
|
|
1287
|
+
secret: parseClaimSecret(claim.secret),
|
|
1288
|
+
trustedOwners: this.trustedOwnerStore,
|
|
1289
|
+
recordClaim: claim.record,
|
|
1290
|
+
onClaimed: (accepted) => this.emit('claim:accepted', accepted),
|
|
1291
|
+
});
|
|
1292
|
+
log('Node starts awaiting a claim; start() enables the seed listener once the control node is up');
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Is this node waiting to be claimed: started with `config.claim` and owned by nobody
|
|
1296
|
+
* yet (empty anchor)? Read live from the anchor every time, as
|
|
1297
|
+
* {@link admitControlPeerUnconditionally} reads it per stream: the anchor is the one
|
|
1298
|
+
* source of truth for "claimed", and a cached flag would need its own invalidation
|
|
1299
|
+
* from the policy's persist, the rollback and a restart. False on a node with no claim
|
|
1300
|
+
* secret, on a claimed one (restarted with the secret still set or not), and before
|
|
1301
|
+
* {@link start} has built the anchor.
|
|
1302
|
+
*
|
|
1303
|
+
* While true the node admits strangers' CONNECTIONS (the claim seed has to ride one)
|
|
1304
|
+
* and refuses their control-database streams ({@link authorizeInboundControlStream})
|
|
1305
|
+
* and relay reservations ({@link admitControlRelayReservation}): an unclaimed node has
|
|
1306
|
+
* no siblings to replicate from and must not become a public relay while it waits.
|
|
1307
|
+
* cadre-cli reports this on `/status`; `claim:accepted` is the event form.
|
|
1308
|
+
*/
|
|
1309
|
+
isAwaitingClaim() {
|
|
1310
|
+
return this.config.claim !== undefined && this.trustedOwnerStore?.all().size === 0;
|
|
1311
|
+
}
|
|
1190
1312
|
/**
|
|
1191
1313
|
* Construct (or adopt) the node-local cold-start bootstrap-peer store (see
|
|
1192
1314
|
* {@link bootstrapPeerStore}). Synchronous: an injected store has already
|
|
@@ -1321,15 +1443,16 @@ export class CadreNode {
|
|
|
1321
1443
|
}
|
|
1322
1444
|
/**
|
|
1323
1445
|
* Persist out-of-band-established owner keys into the node-local anchor —
|
|
1324
|
-
* the runtime enrollment seam:
|
|
1325
|
-
*
|
|
1326
|
-
* holds the pins when
|
|
1327
|
-
* pin. Idempotent. ('genesis'
|
|
1328
|
-
* founding key, seeded internally by
|
|
1446
|
+
* the runtime enrollment seam: {@link redeemCadreInvitation} calls it with the
|
|
1447
|
+
* cadre invitation's `ownerKeys` BEFORE dialing a member (so the anchor already
|
|
1448
|
+
* holds the pins when the reply and the rows that follow are judged), and an
|
|
1449
|
+
* embedder calls it with an operator-supplied pin. Idempotent. ('genesis'
|
|
1450
|
+
* provenance is reserved for the node's own founding key, seeded internally by
|
|
1451
|
+
* {@link initializeSeedBootstrap}; 'chain' is written only by {@link syncOwnerAnchor}.)
|
|
1329
1452
|
*
|
|
1330
1453
|
* Validates every key's shape before trusting any of them (all-or-nothing):
|
|
1331
1454
|
* a malformed entry anywhere in `keys` rejects the whole call before a
|
|
1332
|
-
* single key is anchored. For the
|
|
1455
|
+
* single key is anchored. For the invitation route this means a bundle
|
|
1333
1456
|
* carrying one malformed `ownerKeys` entry fails the redemption outright —
|
|
1334
1457
|
* consistent with the anchor's existing whole-record-or-nothing policy for
|
|
1335
1458
|
* a corrupt persisted entry (see `trusted-owner-store.ts`'s
|
|
@@ -1349,19 +1472,23 @@ export class CadreNode {
|
|
|
1349
1472
|
return await createLibp2pNode(this.buildControlNodeOptions());
|
|
1350
1473
|
}
|
|
1351
1474
|
/**
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
1354
|
-
*
|
|
1355
|
-
*
|
|
1356
|
-
*
|
|
1357
|
-
* NOTE: the party id goes in UNENCODED here, unlike in `controlStorageScope`. Safe
|
|
1358
|
-
* today because a party id is locally configured rather than replicated in, and both
|
|
1359
|
-
* ends of a connection derive this string identically — an odd party id yields an odd
|
|
1360
|
-
* but consistent protocol id, not a mismatch or an escaped name. If a party id ever
|
|
1361
|
-
* arrives from the network, encode it here as the storage scope key already does.
|
|
1475
|
+
* Refuse non-members on every members-only protocol of the control node, at one seam
|
|
1476
|
+
* (`control-protocol-guard.ts`). Wake and strand-addr are judged by the live
|
|
1477
|
+
* {@link isAuthorizedMember}, bounded by the asker's own attempt deadline at this
|
|
1478
|
+
* node's declared link; everything a control-database read depends on is judged by
|
|
1479
|
+
* the snapshot ({@link authorizeInboundControlStream}).
|
|
1362
1480
|
*/
|
|
1481
|
+
installControlProtocolGuard(controlNode) {
|
|
1482
|
+
installControlProtocolGuard(controlNode, {
|
|
1483
|
+
partyId: this.config.controlNetwork.partyId,
|
|
1484
|
+
isMemberLive: (peerId) => this.isAuthorizedMember(peerId),
|
|
1485
|
+
isMemberSnapshot: (peerId, protocol) => this.authorizeInboundControlStream(peerId, protocol),
|
|
1486
|
+
liveDecisionTimeoutMs: relayedRequestBudgetMs(this.config.network?.linkRoundTripMs),
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
/** This node's control network name ({@link controlNetworkName}, the one binding for it). */
|
|
1363
1490
|
controlNetworkName() {
|
|
1364
|
-
return
|
|
1491
|
+
return controlNetworkName(this.config.controlNetwork.partyId);
|
|
1365
1492
|
}
|
|
1366
1493
|
/**
|
|
1367
1494
|
* Arm the CONTROL network's peer-join block catch-up: push every block in the
|
|
@@ -1388,6 +1515,8 @@ export class CadreNode {
|
|
|
1388
1515
|
* reconnect re-runs identify), and — because the production join order is
|
|
1389
1516
|
* connect-then-authorize — on every committed membership change via
|
|
1390
1517
|
* {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
|
|
1518
|
+
* The one non-member the gate admits is a device whose invitation
|
|
1519
|
+
* redemption this member is admitting ({@link catchUpRedeemingDevice}).
|
|
1391
1520
|
*
|
|
1392
1521
|
* No-ops (logged) when the embedder configured no control storage or the
|
|
1393
1522
|
* node exposes no key network — the backfill would have nothing to read or
|
|
@@ -1417,7 +1546,7 @@ export class CadreNode {
|
|
|
1417
1546
|
// under — derived from the same networkName binding the node options
|
|
1418
1547
|
// used, never re-spelled here.
|
|
1419
1548
|
protocolPrefix: `/optimystic/${networkName}`,
|
|
1420
|
-
authorizePeer: (peerId) => this.isAuthorizedMember(peerId)
|
|
1549
|
+
authorizePeer: async (peerId) => this.admittingDevices.has(peerId) || await this.isAuthorizedMember(peerId)
|
|
1421
1550
|
}, {
|
|
1422
1551
|
// A shorter settle window than the strand default (1000 ms): the control
|
|
1423
1552
|
// store is small (a party's membership — dozens of blocks), a re-push is
|
|
@@ -1443,6 +1572,39 @@ export class CadreNode {
|
|
|
1443
1572
|
});
|
|
1444
1573
|
this.controlBackfill.start();
|
|
1445
1574
|
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Push this member's control store to a device whose invitation redemption has verified,
|
|
1577
|
+
* and wait for the push, before the redemption seats the invitation's row and writes the
|
|
1578
|
+
* device's rows (the handler's `catchUpDevice`; `CadreInviteHandler.catchUpDeviceIfLive`
|
|
1579
|
+
* says why those writes need it).
|
|
1580
|
+
* The device is admitted at the backfill's gate for the length of the push only.
|
|
1581
|
+
*
|
|
1582
|
+
* Through {@link PeerJoinBackfill.forceCatchUpPeer}, not `catchUpPeer`: the device's own
|
|
1583
|
+
* `peer:identify` scheduled a run a moment earlier, which was denied if it passed the gate
|
|
1584
|
+
* before the device was added here, and `catchUpPeer` would answer that run's in-flight
|
|
1585
|
+
* status with an empty result.
|
|
1586
|
+
*/
|
|
1587
|
+
async catchUpRedeemingDevice(peerId) {
|
|
1588
|
+
const backfill = this.controlBackfill;
|
|
1589
|
+
if (!backfill) {
|
|
1590
|
+
log('No control backfill on this node; device %s is not caught up before its admission', peerId);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
this.admittingDevices.set(peerId, (this.admittingDevices.get(peerId) ?? 0) + 1);
|
|
1594
|
+
try {
|
|
1595
|
+
const result = await backfill.forceCatchUpPeer(peerIdFromString(peerId));
|
|
1596
|
+
log('Device %s caught up before its admission: offered=%d accepted=%d rejected=%d', peerId, result.offered, result.accepted, result.rejected.length);
|
|
1597
|
+
}
|
|
1598
|
+
finally {
|
|
1599
|
+
const remaining = (this.admittingDevices.get(peerId) ?? 1) - 1;
|
|
1600
|
+
if (remaining > 0) {
|
|
1601
|
+
this.admittingDevices.set(peerId, remaining);
|
|
1602
|
+
}
|
|
1603
|
+
else {
|
|
1604
|
+
this.admittingDevices.delete(peerId);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1446
1608
|
/**
|
|
1447
1609
|
* The control database's cache-wrapped raw storage, resolved from
|
|
1448
1610
|
* `config.storage.provider` on first use and then held in {@link controlStorage}
|
|
@@ -1615,20 +1777,25 @@ export class CadreNode {
|
|
|
1615
1777
|
// `announceAddrs` replaces the advertised set (see warnIfAnnounceAddrsDiscardRelay).
|
|
1616
1778
|
...resolveAnnounceAddrs(network),
|
|
1617
1779
|
// The CONTROL node composes the membership admission gate onto any
|
|
1618
|
-
// caller-supplied gater (deny from
|
|
1619
|
-
//
|
|
1620
|
-
//
|
|
1780
|
+
// caller-supplied gater (a deny from the caller's wins on every composed
|
|
1781
|
+
// hook; the membership policy refuses reservations, never connections,
|
|
1782
|
+
// and puts an unplaced peer's connection on the provisional deadline,
|
|
1783
|
+
// derived from THIS node's declared link because this node decides).
|
|
1784
|
+
// Strand cohort nodes keep the raw configured gater (see
|
|
1621
1785
|
// strand-instance-manager.ts) — their peers are legitimately
|
|
1622
1786
|
// cross-party, so cadre membership must not gate them.
|
|
1623
1787
|
connectionGater: createMembershipConnectionGater({
|
|
1624
1788
|
admitInbound: (remotePeerId) => this.admitInboundControlConnection(remotePeerId),
|
|
1625
1789
|
admitRelayReservation: (remotePeerId) => this.admitControlRelayReservation(remotePeerId),
|
|
1626
1790
|
bringUpInFlight: () => this.controlBringUpInFlight
|
|
1627
|
-
}, network?.connectionGater, ADMISSION_DECISION_TIMEOUT_MS,
|
|
1791
|
+
}, network?.connectionGater, ADMISSION_DECISION_TIMEOUT_MS, relayedRequestBudgetMs(network?.linkRoundTripMs)),
|
|
1628
1792
|
// Fail-closed per-stream authorization for the four Optimystic control-DB
|
|
1629
1793
|
// protocols — the members-only layer the connection gater's stranger
|
|
1630
1794
|
// carve-outs cannot express (see authorizeInboundControlStream). Control
|
|
1631
|
-
// node only: strand cohort nodes serve cross-party peers.
|
|
1795
|
+
// node only: strand cohort nodes serve cross-party peers. The protocol guard
|
|
1796
|
+
// (installControlProtocolGuard) checks these four with this same predicate
|
|
1797
|
+
// first; Optimystic's own check is kept because it is a working, supported
|
|
1798
|
+
// upstream defence, not a second policy.
|
|
1632
1799
|
authorizeInboundStream: (remotePeerId, protocol) => this.authorizeInboundControlStream(remotePeerId, protocol)
|
|
1633
1800
|
};
|
|
1634
1801
|
return nodeOptions;
|
|
@@ -1647,86 +1814,85 @@ export class CadreNode {
|
|
|
1647
1814
|
log('Relay server on: applyDefaultLimit=%s maxReservations=%d reservationTtl=%dms unauthorizedCap=%d', applyDefaultLimit, maxReservations, reservationTtl, this.unauthorizedRelayReservations.cap);
|
|
1648
1815
|
}
|
|
1649
1816
|
/**
|
|
1650
|
-
* Decide
|
|
1651
|
-
*
|
|
1652
|
-
* {@link createControlNode}.
|
|
1653
|
-
*
|
|
1654
|
-
* and defers to the fail-closed per-stream gates (see
|
|
1817
|
+
* Decide how an inbound CONTROL-network connection from `remotePeerId` is
|
|
1818
|
+
* admitted — the policy behind the connection gater wired in
|
|
1819
|
+
* {@link createControlNode}. Every connection is admitted; the answer decides
|
|
1820
|
+
* only whether it carries the provisional deadline (see
|
|
1655
1821
|
* `membership-connection-gater.ts` for the layer's rationale and the
|
|
1656
|
-
* stranger-open protocol allowlist).
|
|
1822
|
+
* stranger-open protocol allowlist). Asked once when the connection arrives
|
|
1823
|
+
* and again at a provisional connection's deadline.
|
|
1657
1824
|
*
|
|
1658
|
-
* Returns `'admit'` when ANY of:
|
|
1825
|
+
* Returns `'admit'` (no deadline) when ANY of:
|
|
1659
1826
|
* 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
|
|
1660
1827
|
* — not running / DB torn down, absent-or-empty trusted-owner anchor, or
|
|
1661
1828
|
* configured bootstrap/relay infrastructure);
|
|
1662
|
-
* 2.
|
|
1663
|
-
* {@link createInvite}) — the invitee dials in before it is authorized;
|
|
1664
|
-
* 3. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
|
|
1829
|
+
* 2. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
|
|
1665
1830
|
* announced it (over the strand-addr RPC) as the transport peerId of its
|
|
1666
1831
|
* own strand node, so a NAT'd member's strand node can hold a
|
|
1667
1832
|
* circuit-relay reservation here (see `delegate-admission.ts`).
|
|
1668
1833
|
* Connection only: the per-stream gate below never honors a grant;
|
|
1669
|
-
*
|
|
1834
|
+
* 3. the authorized-member set is empty — cold start: the rows that would
|
|
1670
1835
|
* authorize anyone arrive by replication over these very connections;
|
|
1671
|
-
*
|
|
1672
|
-
*
|
|
1836
|
+
* 4. the peer IS an authorized member; or
|
|
1837
|
+
* 5. an open invitation is OUTSTANDING — at least one unexpired,
|
|
1673
1838
|
* not-fully-consumed invitation this node minted or persisted (see
|
|
1674
1839
|
* `StrandSolicitationService.hasOutstandingInvitation`). A formation
|
|
1675
1840
|
* initiator is another party's peer by design and its token is only
|
|
1676
1841
|
* checkable inside the protocol, so the gate asks the coarser question
|
|
1677
1842
|
* "does this node expect a stranger at all?". REGISTERING the responder
|
|
1678
|
-
* does not
|
|
1679
|
-
*
|
|
1843
|
+
* does not count: every node registers one at {@link start}, and
|
|
1844
|
+
* registering mints no invitation; or
|
|
1845
|
+
* 6. a LIVE cadre invitation exists — a `CadreInvite` row this node holds
|
|
1846
|
+
* that is not withdrawn, unexpired, has uses left and whose issuer is
|
|
1847
|
+
* still an owner (`ControlDatabase.hasLiveCadreInvite`). The device that
|
|
1848
|
+
* redeems it is a stranger until the redemption writes its row, and its
|
|
1849
|
+
* proof of possession is only checkable inside `/sereus/cadre-invite/1.0.0`;
|
|
1850
|
+
* same reasoning as check 5, and the same expectation-of-a-stranger key.
|
|
1680
1851
|
*
|
|
1681
1852
|
* Ordering is semantically free (the checks are OR'd) but decides who pays:
|
|
1682
|
-
* checks 1-
|
|
1683
|
-
*
|
|
1853
|
+
* checks 1-2 are in-memory, 3/4 share one control-DB read, and only a peer
|
|
1854
|
+
* that is otherwise headed for a provisional admission reaches check 5's and
|
|
1855
|
+
* 6's invitation lookups.
|
|
1684
1856
|
*
|
|
1685
|
-
* Caveats of check
|
|
1857
|
+
* Caveats of check 5:
|
|
1686
1858
|
* - the in-memory mint registry dies with the process, so after a restart
|
|
1687
1859
|
* only invitations persisted as `FormationInvite` rows still hold the
|
|
1688
|
-
*
|
|
1860
|
+
* window open (re-mint otherwise);
|
|
1689
1861
|
* - a peer holding a token whose `FormationInvite` row has not replicated to
|
|
1690
|
-
* this node yet is
|
|
1691
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
* When every check falls through
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
*
|
|
1697
|
-
*
|
|
1698
|
-
*
|
|
1699
|
-
*
|
|
1700
|
-
*
|
|
1701
|
-
*
|
|
1702
|
-
* {@link admitControlRelayReservation}
|
|
1703
|
-
*
|
|
1704
|
-
*
|
|
1705
|
-
*
|
|
1706
|
-
* NOTE: check 4/5 runs a control-DB read per inbound connection
|
|
1862
|
+
* this node yet is admitted only provisionally, so its connection is closed
|
|
1863
|
+
* at the deadline unless the row has landed by then.
|
|
1864
|
+
*
|
|
1865
|
+
* When every check falls through: `'admit-provisionally'`, on every node,
|
|
1866
|
+
* relay server or not. The gate sees only a peer id, so it cannot tell an
|
|
1867
|
+
* outsider from a device whose invitation row this node has not received yet
|
|
1868
|
+
* (it redeems here by seating the row from its bundle, after which check 6
|
|
1869
|
+
* admits) or from a sibling whose membership row is still replicating (check 4
|
|
1870
|
+
* admits once it lands; if it has not landed by the deadline, the connection
|
|
1871
|
+
* is closed and either side's next reconcile dial re-forms it, since outbound
|
|
1872
|
+
* dials are never gated). The protocol guard refuses all of them every
|
|
1873
|
+
* members-only protocol meanwhile. A circuit-relay reservation admitted by
|
|
1874
|
+
* {@link admitControlRelayReservation} disarms the deadline (see
|
|
1875
|
+
* `membership-connection-gater.ts` → "The relay-reservation seam").
|
|
1876
|
+
*
|
|
1877
|
+
* NOTE: check 3/4 runs a control-DB read per inbound connection
|
|
1707
1878
|
* (`listAuthorizedMembers`); connections are rare and cadres small, and this
|
|
1708
1879
|
* layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
|
|
1709
1880
|
* read is safe here — unlike the per-stream gate, which must consult the
|
|
1710
1881
|
* materialized {@link authorizedControlPeers} snapshot instead.
|
|
1711
|
-
* NOTE:
|
|
1712
|
-
* stranger with no locally minted invitation in play, on
|
|
1713
|
-
* node
|
|
1714
|
-
*
|
|
1715
|
-
* the
|
|
1716
|
-
* NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
|
|
1717
|
-
* replicated here is denied until the row converges (typically via the
|
|
1718
|
-
* owner); either side's next outbound reconcile dial (outbound is never
|
|
1719
|
-
* gated) re-establishes the DATA link — self-healing for data, visible as a
|
|
1720
|
-
* transient deny. That self-healing story never covered a reservation, which
|
|
1721
|
-
* is exactly why the relay-enabled path above exists.
|
|
1882
|
+
* NOTE: checks 5 and 6 add two more control reads (`hasOutstandingFormationInvite`,
|
|
1883
|
+
* `hasLiveCadreInvite`) for a stranger with no locally minted invitation in play, on
|
|
1884
|
+
* every node, and a provisional connection's deadline runs the whole decision once
|
|
1885
|
+
* more. If stranger connections ever arrive fast enough for those reads — on arrival
|
|
1886
|
+
* or at the deadline — to show, cache the answers for a few seconds.
|
|
1722
1887
|
*/
|
|
1723
1888
|
async admitInboundControlConnection(remotePeerId) {
|
|
1889
|
+
// NOTE: a node waiting to be claimed (isAwaitingClaim) lands on this empty-anchor
|
|
1890
|
+
// admission too, so a stranger can hold an idle, mute connection to it for as long
|
|
1891
|
+
// as it waits on a public address; if that ever shows up as a resource problem, put
|
|
1892
|
+
// unclaimed-node admissions on the provisional deadline too.
|
|
1724
1893
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
1725
1894
|
return 'admit';
|
|
1726
1895
|
}
|
|
1727
|
-
if (Date.now() < this.enrollmentWindowUntil) {
|
|
1728
|
-
return 'admit';
|
|
1729
|
-
}
|
|
1730
1896
|
if (this.delegateAdmission.has(remotePeerId)) {
|
|
1731
1897
|
return 'admit';
|
|
1732
1898
|
}
|
|
@@ -1741,17 +1907,16 @@ export class CadreNode {
|
|
|
1741
1907
|
if (await this.strandSolicitationService?.hasOutstandingInvitation()) {
|
|
1742
1908
|
return 'admit';
|
|
1743
1909
|
}
|
|
1910
|
+
if (await this.controlDatabase?.hasLiveCadreInvite()) {
|
|
1911
|
+
return 'admit';
|
|
1912
|
+
}
|
|
1744
1913
|
}
|
|
1745
1914
|
catch (error) {
|
|
1746
1915
|
log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
|
|
1747
1916
|
return 'admit';
|
|
1748
1917
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
return 'admit-for-relay';
|
|
1752
|
-
}
|
|
1753
|
-
log('admitInboundControlConnection: DENYING inbound from %s — not an authorized member and no enrollment path open', remotePeerId);
|
|
1754
|
-
return 'deny';
|
|
1918
|
+
log('admitInboundControlConnection: %s is not an authorized member and no stranger window is open — admitting provisionally', remotePeerId);
|
|
1919
|
+
return 'admit-provisionally';
|
|
1755
1920
|
}
|
|
1756
1921
|
/**
|
|
1757
1922
|
* Should `remotePeerId` be granted a circuit-relay reservation slot on this
|
|
@@ -1760,7 +1925,13 @@ export class CadreNode {
|
|
|
1760
1925
|
* "The relay-reservation seam"); the circuit-relay server consults it per
|
|
1761
1926
|
* RESERVE request, so it is never called on a node whose relay server is off.
|
|
1762
1927
|
*
|
|
1763
|
-
*
|
|
1928
|
+
* Refuses everyone, ahead of every admission below, while this node is waiting to be
|
|
1929
|
+
* claimed ({@link isAwaitingClaim}): a storage-profile node runs the relay server by
|
|
1930
|
+
* default, and an unclaimed node on a public address must not become anyone's relay.
|
|
1931
|
+
* The empty-anchor admission in check 1 exists for a node that will be enrolled by
|
|
1932
|
+
* its siblings, which an unclaimed node has none of.
|
|
1933
|
+
*
|
|
1934
|
+
* Otherwise admits when ANY of:
|
|
1764
1935
|
* 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
|
|
1765
1936
|
* — not running / DB torn down, absent-or-empty trusted-owner anchor, or
|
|
1766
1937
|
* configured bootstrap/relay infrastructure);
|
|
@@ -1780,14 +1951,18 @@ export class CadreNode {
|
|
|
1780
1951
|
* that boots, reserves, and only then has its row replicate here would
|
|
1781
1952
|
* otherwise keep that slot spent for the rest of the entry's TTL.
|
|
1782
1953
|
*
|
|
1783
|
-
* The connection gate's stranger carve-outs (an
|
|
1784
|
-
*
|
|
1785
|
-
* exist so a stranger's
|
|
1786
|
-
* neither needs relay capacity. A genuine invitee that does need a relay
|
|
1787
|
-
* takes one from the budget like any other unplaced peer, so
|
|
1788
|
-
* never becomes an unbounded grant of this node's forwarding capacity.
|
|
1954
|
+
* The connection gate's stranger carve-outs (an outstanding formation
|
|
1955
|
+
* invitation, a live cadre invitation) deliberately do NOT extend here: they
|
|
1956
|
+
* exist so a stranger's FORMATION or CADRE-INVITE stream can ride a connection,
|
|
1957
|
+
* and neither needs relay capacity. A genuine invitee that does need a relay
|
|
1958
|
+
* slot takes one from the budget like any other unplaced peer, so a live
|
|
1959
|
+
* invitation never becomes an unbounded grant of this node's forwarding capacity.
|
|
1789
1960
|
*/
|
|
1790
1961
|
async admitControlRelayReservation(remotePeerId) {
|
|
1962
|
+
if (this.isAwaitingClaim()) {
|
|
1963
|
+
log('admitControlRelayReservation: REFUSING %s — this node is unclaimed and relays for nobody until it is', remotePeerId);
|
|
1964
|
+
return false;
|
|
1965
|
+
}
|
|
1791
1966
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
1792
1967
|
return this.admitReservationUncounted(remotePeerId);
|
|
1793
1968
|
}
|
|
@@ -1807,24 +1982,13 @@ export class CadreNode {
|
|
|
1807
1982
|
this.unauthorizedRelayReservations.release(remotePeerId);
|
|
1808
1983
|
return true;
|
|
1809
1984
|
}
|
|
1810
|
-
/**
|
|
1811
|
-
* Hold the control-network inbound gate open for not-yet-authorized peers
|
|
1812
|
-
* until `untilEpochMs` (extends, never shrinks, an already-open window).
|
|
1813
|
-
* {@link createInvite} calls this automatically; a host running an
|
|
1814
|
-
* out-of-band enrollment flow (e.g. accepting a phone whose invite this node
|
|
1815
|
-
* never minted) can open it explicitly before the stranger dials in.
|
|
1816
|
-
*/
|
|
1817
|
-
openEnrollmentWindow(untilEpochMs) {
|
|
1818
|
-
this.enrollmentWindowUntil = Math.max(this.enrollmentWindowUntil, untilEpochMs);
|
|
1819
|
-
log('Enrollment window open until %d', this.enrollmentWindowUntil);
|
|
1820
|
-
}
|
|
1821
1985
|
/**
|
|
1822
1986
|
* Record (or refresh) a delegate admission grant: `delegatePeerId` is the
|
|
1823
1987
|
* transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
|
|
1824
1988
|
* at the CONNECTION and RESERVATION levels (all a circuit-relay reservation
|
|
1825
1989
|
* needs, without spending the unauthorized-reservation budget) for
|
|
1826
|
-
* `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder
|
|
1827
|
-
* authorized
|
|
1990
|
+
* `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder, which the
|
|
1991
|
+
* protocol guard admits only authorized members to; public so tests can drive the
|
|
1828
1992
|
* admission policy without a full strand launch. A re-announce for the same
|
|
1829
1993
|
* (announcer, strand) REPLACES the previous delegate rather than
|
|
1830
1994
|
* accumulating. Never honored by {@link authorizeInboundControlStream}.
|
|
@@ -1863,25 +2027,35 @@ export class CadreNode {
|
|
|
1863
2027
|
return this.getBootstrapPeerIds().has(remotePeerId);
|
|
1864
2028
|
}
|
|
1865
2029
|
/**
|
|
1866
|
-
* Per-stream authorization for the
|
|
1867
|
-
* (
|
|
1868
|
-
*
|
|
1869
|
-
*
|
|
1870
|
-
*
|
|
2030
|
+
* Per-stream authorization for the protocol guard's `members-snapshot` class
|
|
2031
|
+
* (`control-protocol-guard.ts`): the four Optimystic control-DB protocols
|
|
2032
|
+
* (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), FRET's
|
|
2033
|
+
* five, and any protocol nobody classed. Also wired as Optimystic's
|
|
2034
|
+
* `authorizeInboundStream` in {@link createControlNode} for the four DB
|
|
2035
|
+
* protocols. The fail-closed layer behind the fail-open connection gater.
|
|
2036
|
+
* Control node ONLY: strand cohort nodes legitimately serve cross-party peers.
|
|
1871
2037
|
*
|
|
1872
2038
|
* The STRICT SUBSET of {@link admitInboundControlConnection}: the same
|
|
1873
2039
|
* "no basis to judge" admissions (shared via
|
|
1874
2040
|
* {@link admitControlPeerUnconditionally}), minus the stranger carve-outs
|
|
1875
|
-
* (
|
|
1876
|
-
* grant). The first two exist so a stranger can reach `/sereus/
|
|
1877
|
-
* and `/sereus/
|
|
1878
|
-
* stranger to `repo`
|
|
2041
|
+
* (outstanding open invitation, live cadre invitation, delegate admission
|
|
2042
|
+
* grant). The first two exist so a stranger can reach `/sereus/formation/1.0.0`
|
|
2043
|
+
* and `/sereus/cadre-invite/1.0.0` — neither is gated here, and admitting a
|
|
2044
|
+
* stranger to `repo` while an invitation is live is exactly the hole this
|
|
1879
2045
|
* gate closes. A DELEGATE-admitted connection (a member's strand node
|
|
1880
2046
|
* holding a circuit-relay reservation, see `delegate-admission.ts`) is
|
|
1881
2047
|
* likewise exactly a case this gate must still refuse: the delegate gets
|
|
1882
2048
|
* the connection and never the control DB.
|
|
1883
2049
|
*
|
|
1884
|
-
*
|
|
2050
|
+
* Refuses everyone, ahead of every admission below, while this node is waiting to be
|
|
2051
|
+
* claimed ({@link isAwaitingClaim}). The empty-anchor and empty-snapshot admissions
|
|
2052
|
+
* exist so a brand-new node can take replication from the siblings that will
|
|
2053
|
+
* authorize it; an unclaimed node has no siblings yet, and the rows that will
|
|
2054
|
+
* authorize anyone arrive only after the claim, over the owner's streams that the
|
|
2055
|
+
* same two admissions then let in. Configured bootstrap infra is refused too: a
|
|
2056
|
+
* relay opens no control-database stream.
|
|
2057
|
+
*
|
|
2058
|
+
* Otherwise admits when ANY of:
|
|
1885
2059
|
* 1. a shared-baseline check admits (not running / no DB, empty anchor,
|
|
1886
2060
|
* configured bootstrap infra);
|
|
1887
2061
|
* 2. the materialized authorized set is empty — cold start, before the
|
|
@@ -1904,6 +2078,10 @@ export class CadreNode {
|
|
|
1904
2078
|
* ever becomes a supported deployment, this gate needs a key-based admission.
|
|
1905
2079
|
*/
|
|
1906
2080
|
authorizeInboundControlStream(remotePeerId, protocol) {
|
|
2081
|
+
if (this.isAwaitingClaim()) {
|
|
2082
|
+
log('authorizeInboundControlStream: DENYING %s on %s — this node is unclaimed and replicates with nobody until it is', remotePeerId, protocol);
|
|
2083
|
+
return false;
|
|
2084
|
+
}
|
|
1907
2085
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
1908
2086
|
return true;
|
|
1909
2087
|
}
|
|
@@ -1931,13 +2109,19 @@ export class CadreNode {
|
|
|
1931
2109
|
* read here would sleep its backoff holding the lock and stall every other local
|
|
1932
2110
|
* writer. Nothing is lost — this refresh already keeps the previous snapshot on
|
|
1933
2111
|
* failure and is re-driven by the next membership write and by the timed reconcile.
|
|
2112
|
+
*
|
|
2113
|
+
* The trusted-owner anchor sync ({@link syncOwnerAnchor}) runs FIRST, so the snapshot
|
|
2114
|
+
* built here judges `CadrePeer` rows against the anchor as the `OwnerKey` table now
|
|
2115
|
+
* has it. The invitation chain both reads may need is loaded at most once per refresh.
|
|
1934
2116
|
*/
|
|
1935
2117
|
async refreshAuthorizedControlPeers(reason) {
|
|
1936
2118
|
if (!this._running || !this.controlDatabase) {
|
|
1937
2119
|
return;
|
|
1938
2120
|
}
|
|
1939
2121
|
try {
|
|
1940
|
-
const
|
|
2122
|
+
const loadChain = this.invitationChainSource(this.controlDatabase, false);
|
|
2123
|
+
await this.syncOwnerAnchor(reason, false, loadChain);
|
|
2124
|
+
const members = await this.collectAuthorizedMembers(this.controlDatabase, false, loadChain);
|
|
1941
2125
|
this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
|
|
1942
2126
|
log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
|
|
1943
2127
|
// Remember the party's size for the NEXT launch's control-node repair yardstick
|
|
@@ -2159,6 +2343,11 @@ export class CadreNode {
|
|
|
2159
2343
|
}
|
|
2160
2344
|
catch (error) {
|
|
2161
2345
|
// Background task — a failed publish must not crash the node.
|
|
2346
|
+
// NOTE: a restarted machine whose own CadrePeer row replicates in while this update
|
|
2347
|
+
// runs fails here with ConcurrentModificationError (seen on the owner's restart in
|
|
2348
|
+
// every traced run of cadre-invite-any-member), leaving its record unrefreshed until
|
|
2349
|
+
// the next heartbeat. If a restarted machine's changed addresses must publish sooner,
|
|
2350
|
+
// retry once here.
|
|
2162
2351
|
log('Self-registration failed: %o', error);
|
|
2163
2352
|
}
|
|
2164
2353
|
// Wire the ongoing refresh + control-cohort reconcile cadence, then run
|
|
@@ -2227,7 +2416,9 @@ export class CadreNode {
|
|
|
2227
2416
|
const addrs = await this.collectSelfAddrs();
|
|
2228
2417
|
const existing = await this.controlDatabase.queryPeerRecord(peerId);
|
|
2229
2418
|
if (!existing) {
|
|
2230
|
-
|
|
2419
|
+
// A listener-only service (`enableSeedListener`, a claim node's) holds no owner key and
|
|
2420
|
+
// cannot sign the insert, so it is "no owner service" here too.
|
|
2421
|
+
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
2231
2422
|
// "No row" also covers "revoked": queryPeerRecord reads a row whose StampId is
|
|
2232
2423
|
// retired in Revocation as absent, so a removed node lands here every heartbeat
|
|
2233
2424
|
// and stops refreshing its own record — correct, but say so, or the message reads
|
|
@@ -2362,7 +2553,7 @@ export class CadreNode {
|
|
|
2362
2553
|
*/
|
|
2363
2554
|
signSelfRecord(peerId, signingKey, addrs, existing) {
|
|
2364
2555
|
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
2365
|
-
return signPeerRecord({ peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt }, signingKey.privateKeyB64);
|
|
2556
|
+
return signPeerRecord(this.partyId, { peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt }, signingKey.privateKeyB64);
|
|
2366
2557
|
}
|
|
2367
2558
|
/**
|
|
2368
2559
|
* The ed25519 keypair (base64url) the node signs its own record with — the key
|
|
@@ -2437,6 +2628,27 @@ export class CadreNode {
|
|
|
2437
2628
|
}
|
|
2438
2629
|
return ed25519KeyPairFromLibp2p(this.identityKey);
|
|
2439
2630
|
}
|
|
2631
|
+
/**
|
|
2632
|
+
* Publish this node's own address record from the reconcile pass, for a CLAIMED node that
|
|
2633
|
+
* has not published one this session. Its owner writes its `CadrePeer` row only after the
|
|
2634
|
+
* claim is accepted (`claimNode`), so the row reaches this node by replication, after the
|
|
2635
|
+
* boot-time publish and the first-growth drain have both found nothing to publish; without
|
|
2636
|
+
* this the record would wait for the heartbeat ({@link startRecordRefresh}), and the cadre
|
|
2637
|
+
* could not resolve the node's addresses until then. The reconcile pass is where replicated
|
|
2638
|
+
* membership is picked up, so each pass tries until the first publish lands. A try before
|
|
2639
|
+
* the row has arrived is a `skipped` publish: one `CadrePeer` read. A claimed node later
|
|
2640
|
+
* removed from its cadre never publishes again, so it pays that read on every pass.
|
|
2641
|
+
*/
|
|
2642
|
+
async publishSelfRecordOnceClaimed() {
|
|
2643
|
+
if (!this.config.claim || this.isAwaitingClaim() || this.lastSelfRecordPublishAt !== null) {
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
const outcome = await this.registerSelf().catch((error) => {
|
|
2647
|
+
log('reconcileControlCohort: claimed-node self-record publish failed (continuing): %o', error);
|
|
2648
|
+
return 'skipped';
|
|
2649
|
+
});
|
|
2650
|
+
log('reconcileControlCohort: claimed node self-record publish: %s', outcome);
|
|
2651
|
+
}
|
|
2440
2652
|
/**
|
|
2441
2653
|
* Collect this node's current dialable addresses for publication, signaling
|
|
2442
2654
|
* (`/p2p-circuit`) first. Prefers the best invite/NAT-resolved set and folds
|
|
@@ -2489,12 +2701,14 @@ export class CadreNode {
|
|
|
2489
2701
|
// `/p2p-circuit` address the boot-time publish could not carry). Harmless
|
|
2490
2702
|
// where a late-authorized node's addresses keep churning (NAT/relay rotation
|
|
2491
2703
|
// fire the event anyway), and invisible while parties authorize members before
|
|
2492
|
-
// they boot.
|
|
2493
|
-
//
|
|
2494
|
-
//
|
|
2495
|
-
// list of triggers
|
|
2496
|
-
//
|
|
2497
|
-
//
|
|
2704
|
+
// they boot. A claimed node is always authorized after it boots, and the reconcile
|
|
2705
|
+
// pass publishes for it ({@link publishSelfRecordOnceClaimed}). If late enrollment
|
|
2706
|
+
// becomes the normal path for other nodes too — an invited phone is exactly that —
|
|
2707
|
+
// drop that method's claim condition rather than lengthening this list of triggers:
|
|
2708
|
+
// the membership-change seam (`ControlDatabase.setMembershipChangeListener`) fires
|
|
2709
|
+
// on LOCAL commits only, and the row a late-authorized node waits for is written
|
|
2710
|
+
// elsewhere. Measured while writing `relay-only-control-addr.integration.ts` case 4,
|
|
2711
|
+
// which drives `registerSelf()` explicitly because neither trigger is dependable there.
|
|
2498
2712
|
this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
|
|
2499
2713
|
this.recordRefreshTimer?.unref?.();
|
|
2500
2714
|
const reconcileMs = this.config.network?.controlCohort?.reconcileMs ?? DEFAULT_CONTROL_COHORT_RECONCILE_MS;
|
|
@@ -2596,7 +2810,7 @@ export class CadreNode {
|
|
|
2596
2810
|
return null;
|
|
2597
2811
|
}
|
|
2598
2812
|
// Self-signature over (peerId, addrs, updatedAt).
|
|
2599
|
-
if (!verifyPeerRecordSignature(record)) {
|
|
2813
|
+
if (!verifyPeerRecordSignature(this.partyId, record)) {
|
|
2600
2814
|
// Field detail distinguishes "holding the owner-vouch revision (Sig null,
|
|
2601
2815
|
// no addrs — a not-yet-self-published row)" from a genuinely corrupt or
|
|
2602
2816
|
// mixed-revision row. Sig/addrs are public replicated data.
|
|
@@ -2781,6 +2995,10 @@ export class CadreNode {
|
|
|
2781
2995
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
2782
2996
|
return { dialed: [] };
|
|
2783
2997
|
}
|
|
2998
|
+
await this.publishSelfRecordOnceClaimed();
|
|
2999
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
3000
|
+
return { dialed: [] };
|
|
3001
|
+
}
|
|
2784
3002
|
// Refresh relay delegate grants BEFORE the sibling enumeration below: a
|
|
2785
3003
|
// solo cadre with a party relay has no siblings to dial but must still keep
|
|
2786
3004
|
// its running strands' grants alive (a dropped relay connection re-dials
|
|
@@ -3064,8 +3282,7 @@ export class CadreNode {
|
|
|
3064
3282
|
* addresses ({@link dialPeerAddrs}): the reconcile pass's steady-state sibling
|
|
3065
3283
|
* ({@link dialControlSibling}) and cold-start bootstrap peer
|
|
3066
3284
|
* ({@link dialBootstrapPeer}) dials, and — handed to every
|
|
3067
|
-
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials
|
|
3068
|
-
* `dialInvite`.
|
|
3285
|
+
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials.
|
|
3069
3286
|
*
|
|
3070
3287
|
* See `peer-dial.ts`'s `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` for why a peer's dial is
|
|
3071
3288
|
* bounded as a whole, and `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS` for why each
|
|
@@ -3472,7 +3689,7 @@ export class CadreNode {
|
|
|
3472
3689
|
return;
|
|
3473
3690
|
}
|
|
3474
3691
|
this.pendingRevocations.set(revocation.stampId, revocation);
|
|
3475
|
-
log('%s
|
|
3692
|
+
log('%s tombstone for %s committed while alone (0 control connections); Revocation tombstone ' +
|
|
3476
3693
|
'(stamp %s) queued for re-issue on cohort growth', revocation.tableName, revocation.rowKey, revocation.stampId);
|
|
3477
3694
|
}
|
|
3478
3695
|
/**
|
|
@@ -3539,7 +3756,7 @@ export class CadreNode {
|
|
|
3539
3756
|
}
|
|
3540
3757
|
// 4. In-session pending owner authorize writes.
|
|
3541
3758
|
await this.drainPendingPeerWrites();
|
|
3542
|
-
// 5.
|
|
3759
|
+
// 5. Pending joins this process wrote while alone. Their tombstones went out in step 1.
|
|
3543
3760
|
await this.pendingJoinRunner?.reissueWritesMadeAlone();
|
|
3544
3761
|
}
|
|
3545
3762
|
/**
|
|
@@ -3625,6 +3842,12 @@ export class CadreNode {
|
|
|
3625
3842
|
}
|
|
3626
3843
|
}
|
|
3627
3844
|
catch (error) {
|
|
3845
|
+
// NOTE: a device whose owner key has been removed keeps running this drain (and the
|
|
3846
|
+
// membership re-touches of reissuePendingPeerWrites / reissueAuthoredMembershipRows)
|
|
3847
|
+
// on every growth edge, and once its own tombstone has reached it every attempt
|
|
3848
|
+
// fails the schema's live-owner clause and lands here. Noise, not harm; a node that
|
|
3849
|
+
// learns it has been revoked shutting itself down belongs to the device-removal plan
|
|
3850
|
+
// ticket (remove-a-device-or-app-from-a-cadre).
|
|
3628
3851
|
log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
|
|
3629
3852
|
}
|
|
3630
3853
|
}
|
|
@@ -3847,7 +4070,7 @@ export class CadreNode {
|
|
|
3847
4070
|
const existing = await this.controlDatabase.queryDeviceToken(peerId);
|
|
3848
4071
|
// Strictly increase UpdatedAt even on a same-millisecond re-publish (rotation).
|
|
3849
4072
|
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
3850
|
-
const record = signDeviceTokenRecord({ peerId, platform, token, updatedAt }, signingKey.privateKeyB64);
|
|
4073
|
+
const record = signDeviceTokenRecord(this.partyId, { peerId, platform, token, updatedAt }, signingKey.privateKeyB64);
|
|
3851
4074
|
if (existing) {
|
|
3852
4075
|
await this.controlDatabase.updateSelfDeviceToken(record);
|
|
3853
4076
|
if (this.committedAlone())
|
|
@@ -3935,7 +4158,7 @@ export class CadreNode {
|
|
|
3935
4158
|
}
|
|
3936
4159
|
// Self-signature over (peerId, platform, token, updatedAt), verified against the
|
|
3937
4160
|
// CadrePeer.PublicKey bound to this peerId.
|
|
3938
|
-
if (!verifyDeviceTokenSignature(record, peerRecord.publicKey)) {
|
|
4161
|
+
if (!verifyDeviceTokenSignature(this.partyId, record, peerRecord.publicKey)) {
|
|
3939
4162
|
log('resolveDeviceToken: signature verification failed for %s', peerId);
|
|
3940
4163
|
return null;
|
|
3941
4164
|
}
|
|
@@ -4177,6 +4400,11 @@ export class CadreNode {
|
|
|
4177
4400
|
await this.strandAddrService.shutdown();
|
|
4178
4401
|
this.strandAddrService = null;
|
|
4179
4402
|
}
|
|
4403
|
+
// Unregister the cadre invitation redemption handler, for the same reason.
|
|
4404
|
+
if (this.cadreInviteHandler && this.controlNode) {
|
|
4405
|
+
await this.cadreInviteHandler.unregister(this.controlNode);
|
|
4406
|
+
}
|
|
4407
|
+
this.cadreInviteHandler = null;
|
|
4180
4408
|
// Tear down the push-wake fan-out (releases the notifier's APNs HTTP/2 session).
|
|
4181
4409
|
if (this.pushFanoutService) {
|
|
4182
4410
|
await this.pushFanoutService.close().catch((err) => log('Push fan-out close failed: %o', err));
|
|
@@ -6551,6 +6779,12 @@ export class CadreNode {
|
|
|
6551
6779
|
if (!this.controlNode || !this.controlDatabase) {
|
|
6552
6780
|
throw new Error('CadreNode must be started before initializing seed bootstrap');
|
|
6553
6781
|
}
|
|
6782
|
+
// A hosted node waiting to be claimed never holds an owner key: the claim is its
|
|
6783
|
+
// one trust source, and genesis-anchoring a key here would mark it claimed by
|
|
6784
|
+
// itself. Refused here rather than in start() because this call comes after it.
|
|
6785
|
+
if (this.config.claim) {
|
|
6786
|
+
throw new Error('CadreNode: a node started with `claim` is owned by its claimant and cannot initialize seed bootstrap with an owner key');
|
|
6787
|
+
}
|
|
6554
6788
|
// A node wiring seed-bootstrap with an owner PRIVATE key is declaring that
|
|
6555
6789
|
// key an authority of its own party (founder genesis / self-signing owner)
|
|
6556
6790
|
// — anchor it in the node-local store. The in-memory set updates
|
|
@@ -6559,13 +6793,15 @@ export class CadreNode {
|
|
|
6559
6793
|
// NOTE: non-founder members that also wire seed-bootstrap with their own
|
|
6560
6794
|
// derived key (e.g. the phone joiner path in runOwnerGenesis, which needs
|
|
6561
6795
|
// it to self-publish its CadrePeer row) self-anchor a key that is not a
|
|
6562
|
-
// party authority.
|
|
6563
|
-
//
|
|
6564
|
-
//
|
|
6565
|
-
//
|
|
6566
|
-
//
|
|
6567
|
-
//
|
|
6568
|
-
//
|
|
6796
|
+
// party authority. The anchor sync (`syncOwnerAnchor`) does not correct this:
|
|
6797
|
+
// a `genesis` entry is base, kept whether or not a live `OwnerKey` row vouches
|
|
6798
|
+
// it. Harmless while such a node never mints an invite: the store does not
|
|
6799
|
+
// replicate, so a node trusting itself grants nothing to others. But
|
|
6800
|
+
// `createCadreInvitation` hands out the anchor's contents as the device's pins,
|
|
6801
|
+
// so the moment a non-founder member mints an invitation it exports its own
|
|
6802
|
+
// non-authority key as a cadre owner key. If that becomes reachable (today only
|
|
6803
|
+
// cadre-cli/cadre-host owners mint invitations), gate this self-anchor on the
|
|
6804
|
+
// actual OwnerKey genesis insert instead.
|
|
6569
6805
|
if (this.trustedOwnerStore) {
|
|
6570
6806
|
void this.trustedOwnerStore
|
|
6571
6807
|
.trust(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
|
|
@@ -6574,9 +6810,8 @@ export class CadreNode {
|
|
|
6574
6810
|
await this.installSeedBootstrapService(new SeedBootstrapService({
|
|
6575
6811
|
partyId: this.config.controlNetwork.partyId,
|
|
6576
6812
|
ownerPrivateKey,
|
|
6577
|
-
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
6578
6813
|
...this.seedServiceBudgets(),
|
|
6579
|
-
trustPolicy: this.
|
|
6814
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
6580
6815
|
// Seed trust anchors on the node-local store (seeded just above with this
|
|
6581
6816
|
// node's own genesis key), never on the replicated OwnerKey table.
|
|
6582
6817
|
...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
|
|
@@ -6689,14 +6924,21 @@ export class CadreNode {
|
|
|
6689
6924
|
* 1. it is not this node itself — a node publishes its own `CadrePeer` address
|
|
6690
6925
|
* row so its dialable address rides in seeds, but "self" is not a peer this
|
|
6691
6926
|
* node authorized;
|
|
6692
|
-
* 2. its `CadrePeer` row carries a complete
|
|
6693
|
-
* `VouchSig` all non-null)
|
|
6927
|
+
* 2. its `CadrePeer` row carries a complete proof: EITHER an owner voucher
|
|
6928
|
+
* (`StampId`, `VouchOwner`, `VouchSig` all non-null) OR an invitation admission
|
|
6929
|
+
* (`VouchSig` null, `VouchUsage` naming a `CadreInviteUsage` row);
|
|
6694
6930
|
* 3. `VouchOwner` is in the NODE-LOCAL trusted-owner anchor
|
|
6695
|
-
* ({@link getTrustedOwnerStore})
|
|
6696
|
-
*
|
|
6697
|
-
*
|
|
6698
|
-
*
|
|
6699
|
-
*
|
|
6931
|
+
* ({@link getTrustedOwnerStore}), which follows the replicated `OwnerKey` table
|
|
6932
|
+
* only along chains that verify from this node's out-of-band keys
|
|
6933
|
+
* ({@link syncOwnerAnchor}) — never the table itself, which any stranger can
|
|
6934
|
+
* genesis-pollute; and
|
|
6935
|
+
* 4. the proof verifies: for a voucher, `VouchSig` is that owner's signature over
|
|
6936
|
+
* the row's voucher digest ({@link verifyCadrePeerVoucher}), so the anchored
|
|
6937
|
+
* owner really vouched THIS peer id under THIS row's nonce; for an admission,
|
|
6938
|
+
* the chain row → usage → invitation → anchored issuer verifies link by link
|
|
6939
|
+
* ({@link verifyInvitationAdmission} — the issuer is `VouchOwner`, the invitation's
|
|
6940
|
+
* own owner signature, the holder's and the device's signatures, and the stored
|
|
6941
|
+
* peer key really is the key behind the peer id); and
|
|
6700
6942
|
* 5. the row's `StampId` is NOT retired in `CadreControl.Revocation` — enforced
|
|
6701
6943
|
* upstream in {@link ControlDatabase.queryCadrePeers}, which drops retired rows
|
|
6702
6944
|
* before ANY reader (this predicate included) sees them, so a row resurrected
|
|
@@ -6705,18 +6947,26 @@ export class CadreNode {
|
|
|
6705
6947
|
* local rows) is still inert to every reader that has the tombstone.
|
|
6706
6948
|
*
|
|
6707
6949
|
* Fail-closed at every step: a missing anchor (pre-start), an empty anchor (a
|
|
6708
|
-
* not-yet-enrolled node authorizes no one), a null/partial
|
|
6709
|
-
* unanchored `VouchOwner`,
|
|
6710
|
-
* having an address row is NOT
|
|
6711
|
-
* strand-address
|
|
6950
|
+
* not-yet-enrolled node authorizes no one), a null/partial proof, an
|
|
6951
|
+
* unanchored `VouchOwner`, a bad signature, or a usage or invitation row this node
|
|
6952
|
+
* does not hold all yield "not authorized" — having an address row is NOT
|
|
6953
|
+
* membership. The protocol guard admits wake and strand-address requests by this set,
|
|
6954
|
+
* NOT the addressable one.
|
|
6955
|
+
*
|
|
6956
|
+
* The usage and invitation tables are read only when at least one row needs them,
|
|
6957
|
+
* so a cadre with no invitation-admitted member stays at one read per call.
|
|
6712
6958
|
*
|
|
6713
6959
|
* NOTE (rotation): if a party owner rotates keys and only the NEW key is pinned
|
|
6714
6960
|
* in the anchor, rows the OLD key vouched fail check 3 until re-vouched — a
|
|
6715
6961
|
* legit member goes un-authorized on readers that only pin the new key. Full
|
|
6716
6962
|
* rotation handling (re-vouch on rotate) is the
|
|
6717
|
-
* `flip-strand-membership-rotation-known-gap` work, not this predicate's.
|
|
6963
|
+
* `flip-strand-membership-rotation-known-gap` work, not this predicate's. The
|
|
6964
|
+
* `OwnerKey` table differs: an owner the rotated-out (or removed) key vouched keeps
|
|
6965
|
+
* its place in every anchor that had derived it ({@link syncOwnerAnchor}: a derived
|
|
6966
|
+
* entry stays while its own row is live), and is only out of reach for a machine that
|
|
6967
|
+
* never held it until a remaining owner re-adds it or an invitation pins it.
|
|
6718
6968
|
*
|
|
6719
|
-
* @param retry - Whether the underlying membership
|
|
6969
|
+
* @param retry - Whether the underlying membership reads may retry a transient cluster
|
|
6720
6970
|
* failure. Only {@link refreshAuthorizedControlPeers} passes `false`, because it runs
|
|
6721
6971
|
* as the control database's membership listener with that database's write lock held;
|
|
6722
6972
|
* see its comment.
|
|
@@ -6725,10 +6975,18 @@ export class CadreNode {
|
|
|
6725
6975
|
if (!this.controlDatabase) {
|
|
6726
6976
|
throw new Error('CadreNode must be started before listing members');
|
|
6727
6977
|
}
|
|
6978
|
+
return await this.collectAuthorizedMembers(this.controlDatabase, retry, this.invitationChainSource(this.controlDatabase, retry));
|
|
6979
|
+
}
|
|
6980
|
+
/**
|
|
6981
|
+
* The body of {@link listAuthorizedMembers}, with the invitation chain supplied as a
|
|
6982
|
+
* source so the membership refresh can share one load with {@link syncOwnerAnchor}.
|
|
6983
|
+
*/
|
|
6984
|
+
async collectAuthorizedMembers(controlDatabase, retry, loadChain) {
|
|
6728
6985
|
const selfPeerId = this.peerId?.toString();
|
|
6729
|
-
const rows = await
|
|
6986
|
+
const rows = await controlDatabase.queryCadrePeers(retry);
|
|
6987
|
+
const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
|
|
6730
6988
|
const authorized = rows
|
|
6731
|
-
.filter(row => row.peerId !== selfPeerId && this.
|
|
6989
|
+
.filter(row => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain))
|
|
6732
6990
|
.map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
|
|
6733
6991
|
// A node whose anchor was never seeded (no invite pin, no operator pin, not a
|
|
6734
6992
|
// founder) refuses every wake and strand-addr request, which from the outside
|
|
@@ -6741,9 +6999,151 @@ export class CadreNode {
|
|
|
6741
6999
|
}
|
|
6742
7000
|
/**
|
|
6743
7001
|
* Checks 2–4 of the authorized-membership predicate (see
|
|
6744
|
-
* {@link listAuthorizedMembers}) for one `CadrePeer` row
|
|
6745
|
-
*
|
|
6746
|
-
* (
|
|
7002
|
+
* {@link listAuthorizedMembers}) for one `CadrePeer` row, by the kind of proof it
|
|
7003
|
+
* carries: an owner voucher ({@link hasAnchoredVoucher}) or an invitation admission
|
|
7004
|
+
* ({@link verifyInvitationAdmission} over the usage and invitation rows in `chain`,
|
|
7005
|
+
* loaded only when some row needs them). A row with a `VouchSig` is judged as a
|
|
7006
|
+
* voucher even if it also names a usage — the signature is the stronger proof and the
|
|
7007
|
+
* one an owner re-vouch leaves behind.
|
|
7008
|
+
*/
|
|
7009
|
+
hasAnchoredProof(row, chain) {
|
|
7010
|
+
if (!isInvitationAdmitted(row)) {
|
|
7011
|
+
return this.hasAnchoredVoucher(row);
|
|
7012
|
+
}
|
|
7013
|
+
const usage = chain?.usages.get(row.vouchUsage);
|
|
7014
|
+
const invite = usage === undefined ? undefined : chain?.invites.get(usage.inviteKey);
|
|
7015
|
+
if (usage === undefined || invite === undefined) {
|
|
7016
|
+
return false;
|
|
7017
|
+
}
|
|
7018
|
+
return verifyInvitationAdmission(this.partyId, row, usage, invite, key => this.trustedOwnerStore?.has(key) ?? false);
|
|
7019
|
+
}
|
|
7020
|
+
/**
|
|
7021
|
+
* Recompute the node-local trusted-owner anchor from the replicated `OwnerKey` table along
|
|
7022
|
+
* verifiable chains (`owner-anchor-sync.ts` → {@link deriveOwnerAnchor}), as the first step
|
|
7023
|
+
* of every membership refresh ({@link refreshAuthorizedControlPeers}) so the snapshot built
|
|
7024
|
+
* in the same pass judges `CadrePeer` rows against the updated anchor. A key whose live
|
|
7025
|
+
* row's stored proof verifies against a key already anchored enters under `chain` and
|
|
7026
|
+
* stays while its row is live; a key an anchored owner's verifiable tombstone names
|
|
7027
|
+
* leaves, base pins included — which is how a config pin of a removed key, re-anchored at
|
|
7028
|
+
* every start ({@link initializeTrustedOwnerStore}), is removed again. The derivation
|
|
7029
|
+
* refuses removals that would empty the anchor; they are logged here.
|
|
7030
|
+
*
|
|
7031
|
+
* With an EMPTY anchor the sync does nothing: there is no key to derive from, and a node
|
|
7032
|
+
* waiting to be claimed ({@link isAwaitingClaim}: claim secret set, anchor empty) must
|
|
7033
|
+
* stay empty until the claim anchors its claimant — the replicated table must never seat
|
|
7034
|
+
* the first key. The early return below is the seam that rests on.
|
|
7035
|
+
*
|
|
7036
|
+
* Any read failure skips the whole pass and leaves the anchor as it was; the refresh then
|
|
7037
|
+
* builds its snapshot against that anchor, which is also what its previous snapshot was
|
|
7038
|
+
* judged against. Caught here rather than failing the refresh, because the membership read
|
|
7039
|
+
* tolerates an unreachable `Revocation` block on an isolated node
|
|
7040
|
+
* (`ControlDatabase.readRevokedStampRows`) and `queryRevocations` does not: failing the
|
|
7041
|
+
* whole refresh on that would freeze such a node's gate snapshot.
|
|
7042
|
+
* NOTE: on such a node the sync is skipped at every refresh until the block is held; if an
|
|
7043
|
+
* isolated node ever has to derive owner additions while cut off, give `queryRevocations`
|
|
7044
|
+
* the same cohort-unreachable fallback.
|
|
7045
|
+
*
|
|
7046
|
+
* NOTE: cost per membership refresh is one `OwnerKey` read and one `Revocation` read (the
|
|
7047
|
+
* tombstone list, whose `OwnerKey` stamps are handed to `queryOwnerKeyRows` so it does not
|
|
7048
|
+
* read the retired set again), plus the invitation chain when some owner row is
|
|
7049
|
+
* invitation-admitted — shared with the membership read through `loadChain`; the memo
|
|
7050
|
+
* suggestion on {@link loadInvitationChain} covers that part. Before the Revocation ledger
|
|
7051
|
+
* marker is filed that one read is a cohort consult per refresh, which
|
|
7052
|
+
* `control-founding-consult-budget.spec.ts` pins.
|
|
7053
|
+
*
|
|
7054
|
+
* @param retry - forwarded to every read; `false` on the refresh path, for the reason on
|
|
7055
|
+
* {@link refreshAuthorizedControlPeers}.
|
|
7056
|
+
*/
|
|
7057
|
+
async syncOwnerAnchor(reason, retry, loadChain) {
|
|
7058
|
+
const store = this.trustedOwnerStore;
|
|
7059
|
+
const controlDatabase = this.controlDatabase;
|
|
7060
|
+
if (!store || !controlDatabase) {
|
|
7061
|
+
return;
|
|
7062
|
+
}
|
|
7063
|
+
const before = store.sources();
|
|
7064
|
+
if (before.size === 0) {
|
|
7065
|
+
return;
|
|
7066
|
+
}
|
|
7067
|
+
let target;
|
|
7068
|
+
try {
|
|
7069
|
+
const tombstones = await controlDatabase.queryRevocations(retry);
|
|
7070
|
+
const retired = new Set(tombstones.filter(tombstone => tombstone.tableName === 'OwnerKey').map(tombstone => tombstone.stampId));
|
|
7071
|
+
const rows = await controlDatabase.queryOwnerKeyRows(retry, retired);
|
|
7072
|
+
const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
|
|
7073
|
+
const derivation = deriveOwnerAnchor({ partyId: this.partyId, anchor: before, rows, tombstones, chain });
|
|
7074
|
+
if (derivation.refusedRemovals.length > 0) {
|
|
7075
|
+
log('syncOwnerAnchor(%s): refused %d owner removal(s) that would empty the anchor: %o', reason, derivation.refusedRemovals.length, derivation.refusedRemovals);
|
|
7076
|
+
}
|
|
7077
|
+
target = derivation.target;
|
|
7078
|
+
}
|
|
7079
|
+
catch (error) {
|
|
7080
|
+
log('syncOwnerAnchor(%s): read failed — anchor left as it was: %o', reason, error);
|
|
7081
|
+
return;
|
|
7082
|
+
}
|
|
7083
|
+
await this.applyOwnerAnchor(store, before, target, reason);
|
|
7084
|
+
}
|
|
7085
|
+
/**
|
|
7086
|
+
* Apply one pass's result: `chain` entries for the keys it newly derived, removal of the keys
|
|
7087
|
+
* it started with that are outside `target` — base keys a verifiable tombstone named, and
|
|
7088
|
+
* `chain` entries whose row is gone.
|
|
7089
|
+
*
|
|
7090
|
+
* Diffed against `before`, the snapshot the derivation was computed from, NOT the live store:
|
|
7091
|
+
* the anchor is written out of band while the pass's reads are in flight (an invitation pin,
|
|
7092
|
+
* the genesis anchor, a claim, a seed), and such a key is outside `target` only because the
|
|
7093
|
+
* pass never saw it — judging the live store would remove it. Likewise a key removed
|
|
7094
|
+
* meanwhile (a claim rollback) must not come back as `chain`. Both are judged by the next
|
|
7095
|
+
* pass. Each change lands in memory synchronously (`TrustedOwnerStore.trust` / `remove`); a
|
|
7096
|
+
* failed PERSIST is logged and does not stop the pass or fail the refresh, as
|
|
7097
|
+
* `SeedBootstrapService.anchorAcceptedSigner` treats the same failure.
|
|
7098
|
+
*/
|
|
7099
|
+
async applyOwnerAnchor(store, before, target, reason) {
|
|
7100
|
+
for (const key of target) {
|
|
7101
|
+
if (!before.has(key) && !store.has(key)) {
|
|
7102
|
+
log('syncOwnerAnchor(%s): owner %s derived from the OwnerKey table', reason, key);
|
|
7103
|
+
await store.trust(key, 'chain').catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
|
|
7104
|
+
}
|
|
7105
|
+
}
|
|
7106
|
+
for (const key of before.keys()) {
|
|
7107
|
+
if (!target.has(key) && store.has(key)) {
|
|
7108
|
+
log('syncOwnerAnchor(%s): owner %s removed from the anchor', reason, key);
|
|
7109
|
+
await store.remove(key).catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
|
|
7110
|
+
}
|
|
7111
|
+
}
|
|
7112
|
+
}
|
|
7113
|
+
/**
|
|
7114
|
+
* A per-refresh {@link InvitationChainSource}: the first call loads the chain through
|
|
7115
|
+
* {@link loadInvitationChain}, every later call shares that load.
|
|
7116
|
+
*/
|
|
7117
|
+
invitationChainSource(controlDatabase, retry) {
|
|
7118
|
+
let loading = null;
|
|
7119
|
+
return () => (loading ?? (loading = this.loadInvitationChain(controlDatabase, retry)));
|
|
7120
|
+
}
|
|
7121
|
+
/**
|
|
7122
|
+
* The usage and invitation rows {@link hasAnchoredProof} and {@link syncOwnerAnchor}
|
|
7123
|
+
* resolve an invitation-admitted row through, keyed for lookup. Both reads honour `retry`
|
|
7124
|
+
* for {@link listAuthorizedMembers}' reason.
|
|
7125
|
+
*
|
|
7126
|
+
* NOTE: once invitations are the only admission route (ticket
|
|
7127
|
+
* `cadre-invitations-redeemable-by-any-member`), every cadre with a member not yet
|
|
7128
|
+
* re-vouched pays these two whole-table reads and three ed25519 verifies per row on every
|
|
7129
|
+
* membership refresh. Both tables are append-only and a verified chain never changes (expiry,
|
|
7130
|
+
* use count and withdrawal are not re-checked), so if the gate's admission deadline ever
|
|
7131
|
+
* shows this, memoise verified (row stamp → usage stamp) pairs per node and skip the reads
|
|
7132
|
+
* when every admitted row is already memoised.
|
|
7133
|
+
*/
|
|
7134
|
+
async loadInvitationChain(controlDatabase, retry) {
|
|
7135
|
+
const [usages, invites] = await Promise.all([
|
|
7136
|
+
controlDatabase.queryCadreInviteUsages(retry),
|
|
7137
|
+
controlDatabase.queryCadreInvites(retry),
|
|
7138
|
+
]);
|
|
7139
|
+
return {
|
|
7140
|
+
usages: new Map(usages.map(usage => [usage.usageStampId, usage])),
|
|
7141
|
+
invites: new Map(invites.map(invite => [invite.key, invite])),
|
|
7142
|
+
};
|
|
7143
|
+
}
|
|
7144
|
+
/**
|
|
7145
|
+
* The owner-voucher half of {@link hasAnchoredProof}: complete voucher, `VouchOwner` in
|
|
7146
|
+
* the node-local anchor, signature valid over the row's (PeerId, StampId) voucher digest.
|
|
6747
7147
|
*
|
|
6748
7148
|
* NOTE: verifies the ed25519 signature on every call (no memo of already-
|
|
6749
7149
|
* verified (peerId, stampId, vouchSig) triples). Cadres are a handful of
|
|
@@ -6758,12 +7158,12 @@ export class CadreNode {
|
|
|
6758
7158
|
if (!this.trustedOwnerStore?.has(row.vouchOwner)) {
|
|
6759
7159
|
return false;
|
|
6760
7160
|
}
|
|
6761
|
-
return verifyCadrePeerVoucher(row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
|
|
7161
|
+
return verifyCadrePeerVoucher(this.partyId, row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
|
|
6762
7162
|
}
|
|
6763
7163
|
/**
|
|
6764
7164
|
* Probe whether a given peer is an AUTHORIZED party member (see
|
|
6765
|
-
* {@link listAuthorizedMembers}) — the
|
|
6766
|
-
* strand-address
|
|
7165
|
+
* {@link listAuthorizedMembers}) — the check the protocol guard runs ahead of the
|
|
7166
|
+
* control-network wake and strand-address handlers, NOT {@link isMember} (the addressable
|
|
6767
7167
|
* surface). Deliberately scans the full membership (one `CadrePeer` query)
|
|
6768
7168
|
* rather than adding a single-row read path: cadres are small, and one code
|
|
6769
7169
|
* path keeps the predicate impossible to drift from the list.
|
|
@@ -6779,14 +7179,16 @@ export class CadreNode {
|
|
|
6779
7179
|
* record (via {@link resolvePeerAddrs}, signaling/relay first — so a NAT'd peer
|
|
6780
7180
|
* is reachable through its circuit-relay address), dials `WAKE_PROTOCOL`, sends
|
|
6781
7181
|
* the {@link WakeRequest}, and returns the peer's {@link WakeAck}. The receiver
|
|
6782
|
-
*
|
|
6783
|
-
*
|
|
6784
|
-
*
|
|
7182
|
+
* only resumes a strand it already participates in; it acks once it has decided,
|
|
7183
|
+
* before the strand is up. The dial deadlines derive from this node's
|
|
7184
|
+
* `network.linkRoundTripMs`.
|
|
6785
7185
|
*
|
|
6786
7186
|
* @param targetPeerId - The hibernating cadre peer to wake.
|
|
6787
7187
|
* @param strandId - The strand the caller knows has pending activity.
|
|
6788
7188
|
* @param reason - Optional cause hint, e.g. `"activity"` or `"manual"`.
|
|
6789
|
-
* @throws if the node is not started
|
|
7189
|
+
* @throws if the node is not started, the target has no dialable address, or no
|
|
7190
|
+
* attempt got an ack — including when the receiver does not count this node as an
|
|
7191
|
+
* authorized member, since its protocol guard resets the stream without a reply.
|
|
6790
7192
|
*/
|
|
6791
7193
|
async pushWake(targetPeerId, strandId, reason) {
|
|
6792
7194
|
if (!this.controlNode) {
|
|
@@ -6802,7 +7204,8 @@ export class CadreNode {
|
|
|
6802
7204
|
/**
|
|
6803
7205
|
* Enable the seed listener for receiving seeds via the /sereus/seed/1.0.0 protocol.
|
|
6804
7206
|
* This is for drone nodes that need to receive seeds without being an owner.
|
|
6805
|
-
* Does not require an owner key.
|
|
7207
|
+
* Does not require an owner key. Idempotent; a node started with `config.claim`
|
|
7208
|
+
* calls it itself inside {@link start}, so an embedder's own call is a no-op.
|
|
6806
7209
|
*/
|
|
6807
7210
|
async enableSeedListener() {
|
|
6808
7211
|
if (!this.controlNode || !this.controlDatabase) {
|
|
@@ -6816,9 +7219,8 @@ export class CadreNode {
|
|
|
6816
7219
|
await this.installSeedBootstrapService(new SeedBootstrapService({
|
|
6817
7220
|
partyId: this.config.controlNetwork.partyId,
|
|
6818
7221
|
// No owner key - this node only receives seeds
|
|
6819
|
-
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
6820
7222
|
...this.seedServiceBudgets(),
|
|
6821
|
-
trustPolicy: this.
|
|
7223
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
6822
7224
|
// A listener-only node accepts a wire-delivered seed solely against this
|
|
6823
7225
|
// anchor (there is no per-call override on the inbound handler): with no
|
|
6824
7226
|
// genesis/invite/operator pin it authorizes nobody, which is the point.
|
|
@@ -6881,6 +7283,17 @@ export class CadreNode {
|
|
|
6881
7283
|
onSeedError: (partyId, error) => this.emit('seed:error', { partyId, error }),
|
|
6882
7284
|
};
|
|
6883
7285
|
}
|
|
7286
|
+
/**
|
|
7287
|
+
* The node-wide default trust policy for INBOUND seeds, the one value every
|
|
7288
|
+
* {@link SeedBootstrapService} this node constructs is given — the owner service,
|
|
7289
|
+
* the listener, and `applySeed`'s temp service — so the three cannot drift. The
|
|
7290
|
+
* claim policy when the node was started with `config.claim` (the two are refused
|
|
7291
|
+
* together at start, so there is nothing to merge), otherwise the configured
|
|
7292
|
+
* `seedTrustPolicy`, and `undefined` for the service's anchored default.
|
|
7293
|
+
*/
|
|
7294
|
+
inboundSeedTrustPolicy() {
|
|
7295
|
+
return this.claimTrustPolicy ?? this.config.seedTrustPolicy;
|
|
7296
|
+
}
|
|
6884
7297
|
/**
|
|
6885
7298
|
* Re-materialize the authorized-peer snapshot that the fail-closed per-stream
|
|
6886
7299
|
* control-DB gate ({@link authorizeInboundControlStream}) judges against.
|
|
@@ -7035,6 +7448,46 @@ export class CadreNode {
|
|
|
7035
7448
|
log('removePeer: persisting the removal of dial target %s failed (forgotten in memory): %o', peerId, error);
|
|
7036
7449
|
});
|
|
7037
7450
|
}
|
|
7451
|
+
/**
|
|
7452
|
+
* Seat another owner key, signed by this node's owner key (the signed-add branch of
|
|
7453
|
+
* `OwnerKey.Authorized`; the row stores the vouching pair). The write notifies the
|
|
7454
|
+
* membership hub, so the refresh it triggers has run by the time this resolves.
|
|
7455
|
+
*
|
|
7456
|
+
* @param ownerPublicKeyB64 - the new owner's ed25519 public key (base64url).
|
|
7457
|
+
* @returns `true` when the row was seated, `false` when the key is already a live owner.
|
|
7458
|
+
*/
|
|
7459
|
+
async addOwner(ownerPublicKeyB64) {
|
|
7460
|
+
if (!this.seedBootstrapService) {
|
|
7461
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7462
|
+
}
|
|
7463
|
+
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
7464
|
+
const added = await this.seedBootstrapService.addOwner(key);
|
|
7465
|
+
// NOTE: an OwnerKey add committed alone is local-only and nothing re-issues it (no
|
|
7466
|
+
// update branch to re-touch, unlike CadrePeer), so it is only logged;
|
|
7467
|
+
// tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue.md owns the fix.
|
|
7468
|
+
if (added && this.committedAlone()) {
|
|
7469
|
+
log('addOwner(%s) committed while ALONE (0 control connections): the new owner row is ' +
|
|
7470
|
+
'local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
|
|
7471
|
+
}
|
|
7472
|
+
return added;
|
|
7473
|
+
}
|
|
7474
|
+
/**
|
|
7475
|
+
* Remove an owner key, signed by this node's owner key (which must be a different owner):
|
|
7476
|
+
* the delete and its `Revocation` tombstone commit together. Refused by name when the key
|
|
7477
|
+
* is this node's own or the only live owner. The write notifies the membership hub; the
|
|
7478
|
+
* tombstone rides the committed-delete seam ({@link noteGuardedDelete}), so one committed
|
|
7479
|
+
* while alone is re-issued on cohort growth like every other removal.
|
|
7480
|
+
*
|
|
7481
|
+
* @param ownerPublicKeyB64 - the owner's ed25519 public key (base64url).
|
|
7482
|
+
* @returns `true` when the row was removed, `false` when the key is not a live owner here.
|
|
7483
|
+
*/
|
|
7484
|
+
async removeOwner(ownerPublicKeyB64) {
|
|
7485
|
+
if (!this.seedBootstrapService) {
|
|
7486
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7487
|
+
}
|
|
7488
|
+
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
7489
|
+
return await this.seedBootstrapService.removeOwner(key);
|
|
7490
|
+
}
|
|
7038
7491
|
/**
|
|
7039
7492
|
* Create a seed from the current control network state.
|
|
7040
7493
|
* The seed contains peer information and is signed by an owner.
|
|
@@ -7049,10 +7502,12 @@ export class CadreNode {
|
|
|
7049
7502
|
* Apply a seed to populate the peer cache and enable connections.
|
|
7050
7503
|
*
|
|
7051
7504
|
* Validates the seed signature, then evaluates a trust anchor for the signer
|
|
7052
|
-
* key (see `SeedTrustPolicy`). An
|
|
7053
|
-
* `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a
|
|
7054
|
-
*
|
|
7055
|
-
*
|
|
7505
|
+
* key (see `SeedTrustPolicy`). An operator-driven caller can pass a per-seed
|
|
7506
|
+
* `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a pinned
|
|
7507
|
+
* owner key — so a cold-start node can accept its first seed without
|
|
7508
|
+
* reconfiguring the service. The override wins on a node started with
|
|
7509
|
+
* `config.claim` as well: the claim secret is the node's default inbound policy,
|
|
7510
|
+
* not a lock on its local operator, who already controls the process.
|
|
7056
7511
|
*/
|
|
7057
7512
|
async applySeed(seed, options) {
|
|
7058
7513
|
if (!this.seedBootstrapService) {
|
|
@@ -7063,13 +7518,13 @@ export class CadreNode {
|
|
|
7063
7518
|
//
|
|
7064
7519
|
// This temp service is discarded after the call, so it must NOT own the shared
|
|
7065
7520
|
// node's inbound seed handler: pass { registerHandler: false }. That keeps
|
|
7066
|
-
// repeated service-less applySeed
|
|
7521
|
+
// repeated service-less applySeed idempotent (no handler leak, no
|
|
7067
7522
|
// DuplicateProtocolHandlerError). The temp service still applies this seed; a
|
|
7068
7523
|
// node that wants to RECEIVE inbound seeds needs a persistent service
|
|
7069
7524
|
// (enableSeedListener / initializeSeedBootstrap) to own the handler.
|
|
7070
7525
|
const tempService = new SeedBootstrapService({
|
|
7071
7526
|
partyId: seed.partyId,
|
|
7072
|
-
trustPolicy: this.
|
|
7527
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
7073
7528
|
...this.seedServiceBudgets(),
|
|
7074
7529
|
// The anchor is node-scoped, not service-scoped: a throwaway service
|
|
7075
7530
|
// must consult (and persist an accepted signer into) the SAME store the
|
|
@@ -7109,12 +7564,19 @@ export class CadreNode {
|
|
|
7109
7564
|
}
|
|
7110
7565
|
/**
|
|
7111
7566
|
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
7567
|
+
*
|
|
7568
|
+
* `target` is a multiaddr string, or a peer id with its addresses (the form a phone
|
|
7569
|
+
* uses for a node it scanned), which are dialed one at a time before the request
|
|
7570
|
+
* (`SeedBootstrapService.deliverSeed`). `options.claimProof` rides beside the seed when
|
|
7571
|
+
* the target is a brand-new node being claimed (`claim-proof.ts`).
|
|
7572
|
+
*
|
|
7573
|
+
* @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it formed.
|
|
7112
7574
|
*/
|
|
7113
|
-
async deliverSeed(
|
|
7575
|
+
async deliverSeed(target, seed, options) {
|
|
7114
7576
|
if (!this.seedBootstrapService) {
|
|
7115
7577
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7116
7578
|
}
|
|
7117
|
-
return await this.seedBootstrapService.deliverSeed(
|
|
7579
|
+
return await this.seedBootstrapService.deliverSeed(target, seed, options);
|
|
7118
7580
|
}
|
|
7119
7581
|
/**
|
|
7120
7582
|
* Encode a seed for out-of-band delivery (e.g., QR code, copy/paste).
|
|
@@ -7180,31 +7642,61 @@ export class CadreNode {
|
|
|
7180
7642
|
return result;
|
|
7181
7643
|
}
|
|
7182
7644
|
/**
|
|
7183
|
-
*
|
|
7184
|
-
*
|
|
7185
|
-
|
|
7186
|
-
|
|
7645
|
+
* Claim a node that was started with a claim secret (`CadreNodeConfig.claim`), from
|
|
7646
|
+
* the peer id, addresses and secret its host showed (the QR code): mint a seed, prove
|
|
7647
|
+
* possession of the secret beside it (`claim-proof.ts`), deliver it, and on acceptance
|
|
7648
|
+
* vouch the node into this cadre and keep its addresses as a dial target. The owner
|
|
7649
|
+
* side of the claim-secret entry in the Seed Delivery Protocol (`docs/architecture.md`).
|
|
7650
|
+
*
|
|
7651
|
+
* Requires the seed service with an owner key ({@link initializeSeedBootstrap}), as
|
|
7652
|
+
* {@link addDrone} does. The order is the REVERSE of `addDrone`'s: seed first, row
|
|
7653
|
+
* second. A refused claim or a failed delivery therefore leaves no `CadrePeer` row and
|
|
7654
|
+
* no retained dial target behind on this node — a phone that typed the wrong secret,
|
|
7655
|
+
* or scanned a node someone else already owns, has nothing to clean up.
|
|
7656
|
+
*
|
|
7657
|
+
* A failure is one of three kinds a caller can tell apart:
|
|
7658
|
+
*
|
|
7659
|
+
* - `ClaimRefusedError` — the node was reached and refused, with its refusal code when
|
|
7660
|
+
* its policy gave one.
|
|
7661
|
+
* - `PeerUnreachableError` — no connection to the node formed from any of its addresses,
|
|
7662
|
+
* so nothing was sent. Each address is dialed in turn on its own limit, so this can
|
|
7663
|
+
* take up to the per-peer dial limit plus the seed request's (114.5 s at the default
|
|
7664
|
+
* declared link) when none answers.
|
|
7665
|
+
* - anything else — the node was reached and the exchange failed, or a local step failed
|
|
7666
|
+
* (a malformed secret or address among them). Repeating the claim with the same secret
|
|
7667
|
+
* is safe (see below).
|
|
7668
|
+
*
|
|
7669
|
+
* Idempotent for the same owner: a second call after a dropped response is accepted
|
|
7670
|
+
* by the node (its anchored branch), the row insert is a no-op on an existing row
|
|
7671
|
+
* (`ControlDatabase.insertCadrePeer`), and the dial target is replaced, not duplicated.
|
|
7672
|
+
*
|
|
7673
|
+
* The first dial is kicked (not awaited) so it happens now rather than on the next
|
|
7674
|
+
* timed reconcile pass; a caller that wants to wait for the connection awaits
|
|
7675
|
+
* {@link reconcileControlCohort} itself, with `addDrone`'s caveat that a pass already
|
|
7676
|
+
* in flight is joined rather than restarted. The claimed node dials this one back too,
|
|
7677
|
+
* from the seed's owner list, when this node has a listen or relay address; a phone
|
|
7678
|
+
* with neither is reached by its own reconcile pass alone.
|
|
7679
|
+
*
|
|
7680
|
+
* @throws {ClaimRefusedError} when the node was reached and refused the claim.
|
|
7681
|
+
* @throws {PeerUnreachableError} when the node was never reached.
|
|
7682
|
+
*/
|
|
7683
|
+
async claimNode(target) {
|
|
7187
7684
|
if (!this.seedBootstrapService) {
|
|
7188
7685
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7189
7686
|
}
|
|
7190
|
-
const
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
}
|
|
7197
|
-
/**
|
|
7198
|
-
* Accept a phone connection using an invite.
|
|
7199
|
-
* Call this when a phone dials in with an invite token.
|
|
7200
|
-
*/
|
|
7201
|
-
async acceptPhone(options, issuedInvite) {
|
|
7202
|
-
if (!this.seedBootstrapService) {
|
|
7203
|
-
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7687
|
+
const secret = parseClaimSecret(target.secret);
|
|
7688
|
+
const seed = await this.seedBootstrapService.createSeed();
|
|
7689
|
+
const proof = claimProof(secret, target.peerId, seed.signerKey, seedDigest(seed));
|
|
7690
|
+
const ack = await this.seedBootstrapService.deliverSeed({ peerId: target.peerId, multiaddrs: target.multiaddrs }, seed, { claimProof: proof });
|
|
7691
|
+
if (!ack.accepted) {
|
|
7692
|
+
throw new ClaimRefusedError(target.peerId, ack.reason ?? 'no reason given', ack.code);
|
|
7204
7693
|
}
|
|
7205
|
-
|
|
7206
|
-
//
|
|
7207
|
-
|
|
7694
|
+
log('claimNode: %s accepted the claim; vouching it into the cadre', target.peerId);
|
|
7695
|
+
// The wrapper queues the write for re-replication: a phone claiming its first
|
|
7696
|
+
// always-on node has no control connection yet, so the insert commits local-only.
|
|
7697
|
+
await this.authorizePeer(target.peerId, target.multiaddrs);
|
|
7698
|
+
this.retainDialTarget(target.peerId, target.multiaddrs, 'claimNode');
|
|
7699
|
+
void this.reconcileControlCohort().catch((error) => log('claimNode: reconcile kick after claiming %s failed: %o', target.peerId, error));
|
|
7208
7700
|
}
|
|
7209
7701
|
/**
|
|
7210
7702
|
* Add a phone to the cadre with relay support.
|
|
@@ -7220,45 +7712,175 @@ export class CadreNode {
|
|
|
7220
7712
|
// own control-DB schema load may have died denied.
|
|
7221
7713
|
return await this.seedBootstrapService.addPhoneWithRelay(phonePeerId);
|
|
7222
7714
|
}
|
|
7715
|
+
// ============================================================================
|
|
7716
|
+
// Cadre Invitation API (redeemable at any member, `/sereus/cadre-invite/1.0.0`)
|
|
7717
|
+
// ============================================================================
|
|
7223
7718
|
/**
|
|
7224
|
-
*
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
|
|
7228
|
-
|
|
7719
|
+
* Mint an owner-signed cadre invitation that a device can redeem at ANY member machine of
|
|
7720
|
+
* this party, the owner offline included ({@link redeemCadreInvitation}). Requires the
|
|
7721
|
+
* owner key ({@link initializeSeedBootstrap}).
|
|
7722
|
+
*
|
|
7723
|
+
* The invitation is an ed25519 keypair: the public half is the `CadreInvite` row's key,
|
|
7724
|
+
* the private half rides in the returned bundle as the proof of possession. Whoever holds
|
|
7725
|
+
* the bundle can redeem it, so an UNTARGETED, OWNER-GRANTING invitation — a bearer
|
|
7726
|
+
* credential for admin rights — defaults to a 15-minute lifetime; every other kind to 24
|
|
7727
|
+
* hours. One use unless `uses` says otherwise. The bundle carries the signed row (so a
|
|
7728
|
+
* member that has not received it by replication seats it from the bundle), this node's
|
|
7729
|
+
* anchored owner keys — every current owner this node has verified, the out-of-band ones
|
|
7730
|
+
* and those derived along verifiable chains ({@link syncOwnerAnchor}) — (the device pins
|
|
7731
|
+
* them and checks the member's reply against them; sourced from the node-local anchor
|
|
7732
|
+
* only, never the raw replicated `OwnerKey` table, because the device anchors whatever
|
|
7733
|
+
* arrives and a stranger's genesis-inserted key must not ride an invitation into a fresh
|
|
7734
|
+
* node's anchor; an empty anchor is refused because a reply could not be checked), and
|
|
7735
|
+
* the addresses of this machine first
|
|
7736
|
+
* ({@link resolveInviteAddresses}, which honours pushed NAT addresses) then up to three
|
|
7737
|
+
* other members ({@link siblingInvitationAddrs}).
|
|
7738
|
+
*
|
|
7739
|
+
* The row commits locally and replicates like any other control write; minted while
|
|
7740
|
+
* alone (a phone with no connection), it reaches the other members through the
|
|
7741
|
+
* peer-join block catch-up on the next connection. The device can redeem at any member it
|
|
7742
|
+
* reaches meanwhile: a member that does not hold the row yet admits the device's connection
|
|
7743
|
+
* provisionally ({@link admitInboundControlConnection}) and seats the row from the bundle.
|
|
7744
|
+
*
|
|
7745
|
+
* @throws when no owner key is wired, the anchor is empty, `uses` is not a positive
|
|
7746
|
+
* integer, or neither this machine nor any other member has an address.
|
|
7747
|
+
*/
|
|
7748
|
+
async createCadreInvitation(options) {
|
|
7749
|
+
const service = this.seedBootstrapService;
|
|
7750
|
+
if (!service?.canAuthorize()) {
|
|
7751
|
+
throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
|
|
7752
|
+
}
|
|
7753
|
+
const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
|
|
7754
|
+
if (ownerKeys.length === 0) {
|
|
7755
|
+
throw new Error('createCadreInvitation: this node anchors no owner key, and an invitation without owner keys cannot be verified by the device that redeems it');
|
|
7756
|
+
}
|
|
7757
|
+
const uses = options.uses ?? 1;
|
|
7758
|
+
if (!Number.isInteger(uses) || uses < 1) {
|
|
7759
|
+
throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
|
|
7760
|
+
}
|
|
7761
|
+
const members = [...await this.resolveInviteAddresses(), ...await this.siblingInvitationAddrs()];
|
|
7762
|
+
if (members.length === 0) {
|
|
7763
|
+
throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
|
|
7764
|
+
}
|
|
7765
|
+
const expiresInMs = options.expiresInMs
|
|
7766
|
+
?? (options.grantsOwner && !options.peerId ? OWNER_INVITATION_DEFAULT_TTL_MS : CADRE_INVITATION_DEFAULT_TTL_MS);
|
|
7767
|
+
const invitePrivateKey = generatePrivateKey('ed25519', 'base64url');
|
|
7768
|
+
const row = await service.insertCadreInvite({
|
|
7769
|
+
key: ed25519PublicKeyFromPrivate(invitePrivateKey),
|
|
7770
|
+
peerId: options.peerId ?? null,
|
|
7771
|
+
grantsOwner: options.grantsOwner,
|
|
7772
|
+
expiresAtMs: Date.now() + expiresInMs,
|
|
7773
|
+
totalUses: uses,
|
|
7774
|
+
});
|
|
7775
|
+
const invitation = {
|
|
7776
|
+
v: 1,
|
|
7777
|
+
partyId: this.config.controlNetwork.partyId,
|
|
7778
|
+
invitePrivateKey,
|
|
7779
|
+
invite: row,
|
|
7780
|
+
ownerKeys,
|
|
7781
|
+
members,
|
|
7782
|
+
};
|
|
7783
|
+
log('Cadre invitation minted: %s (peer=%s owner=%s uses=%d, %d member address(es))', row.key, row.peerId ?? 'any', row.grantsOwner, uses, members.length);
|
|
7784
|
+
return { invitation, encoded: encodeCadreInvitation(invitation) };
|
|
7229
7785
|
}
|
|
7230
7786
|
/**
|
|
7231
|
-
*
|
|
7787
|
+
* Every cadre invitation this node holds — minted here or replicated in — with its
|
|
7788
|
+
* standing ({@link CadreInviteStatus}: live, withdrawn, redemptions recorded).
|
|
7232
7789
|
*/
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7790
|
+
async listCadreInvitations() {
|
|
7791
|
+
if (!this.controlDatabase) {
|
|
7792
|
+
throw new Error('CadreNode must be started before listing cadre invitations');
|
|
7793
|
+
}
|
|
7794
|
+
return await this.controlDatabase.listCadreInviteStatuses();
|
|
7237
7795
|
}
|
|
7238
7796
|
/**
|
|
7239
|
-
*
|
|
7797
|
+
* Withdraw a cadre invitation: an owner-signed `Revocation` tombstone over its row, which
|
|
7798
|
+
* stays as the proof of membership for every device it admitted. No further redemption
|
|
7799
|
+
* succeeds anywhere the tombstone reaches, and a member holding it refuses the bundle's
|
|
7800
|
+
* copy of the row. A withdrawal committed while alone is re-issued on cohort growth like
|
|
7801
|
+
* any other tombstone.
|
|
7802
|
+
*
|
|
7803
|
+
* @returns `true` when this call filed the tombstone, `false` when the row is not held
|
|
7804
|
+
* here or was already withdrawn.
|
|
7240
7805
|
*/
|
|
7241
|
-
async
|
|
7806
|
+
async withdrawCadreInvitation(key) {
|
|
7242
7807
|
if (!this.seedBootstrapService) {
|
|
7243
|
-
|
|
7244
|
-
// the call, so it does NOT own the shared node's inbound seed handler:
|
|
7245
|
-
// initialize with { registerHandler: false }. This temp dialInvite never
|
|
7246
|
-
// applies a seed itself — a node that wants to RECEIVE an inbound seed back
|
|
7247
|
-
// must have a persistent service (enableSeedListener / initializeSeedBootstrap)
|
|
7248
|
-
// own the /sereus/seed/1.0.0 handler. trustPolicy is therefore effectively dead
|
|
7249
|
-
// on this dial-only path; it is kept for symmetry with the applySeed temp site.
|
|
7250
|
-
const tempService = new SeedBootstrapService({
|
|
7251
|
-
partyId: invite.partyId,
|
|
7252
|
-
trustPolicy: this.config.seedTrustPolicy,
|
|
7253
|
-
...this.seedServiceBudgets(),
|
|
7254
|
-
});
|
|
7255
|
-
if (this.controlNode && this.controlDatabase) {
|
|
7256
|
-
await tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
|
|
7257
|
-
}
|
|
7258
|
-
await tempService.dialInvite(invite);
|
|
7259
|
-
return;
|
|
7808
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7260
7809
|
}
|
|
7261
|
-
await this.seedBootstrapService.
|
|
7810
|
+
return await this.seedBootstrapService.withdrawCadreInvite(key);
|
|
7811
|
+
}
|
|
7812
|
+
/**
|
|
7813
|
+
* Join the cadre an invitation names by redeeming it at one of the member machines it
|
|
7814
|
+
* lists, the owner that minted it offline or not. Needs a started node with a stable
|
|
7815
|
+
* identity (`keyStore` or `privateKey`): the redemption is signed with the key behind
|
|
7816
|
+
* this node's peer id, and the member checks that the two match.
|
|
7817
|
+
*
|
|
7818
|
+
* Steps: refuse an invitation for another party; pin its `ownerKeys` into the node-local
|
|
7819
|
+
* anchor (`'invite'` provenance — the pin sticks even if the redemption then fails, as it
|
|
7820
|
+
* does for a seed invite, so a later attempt or seed from the same owner is anchored);
|
|
7821
|
+
* sign the request; dial the listed members in order ({@link redeemAtMembers}); on
|
|
7822
|
+
* acceptance verify the reply against the pinned keys, merge its dial hints into the peer
|
|
7823
|
+
* store, retain the answering member as a cold-start dial target, refresh the membership
|
|
7824
|
+
* gate and start a cohort reconcile pass. This node then syncs the control database over
|
|
7825
|
+
* the connection it already holds, the same unified behaviour as after a seed.
|
|
7826
|
+
*
|
|
7827
|
+
* A device that is already a member (a restart with the invitation still configured) is
|
|
7828
|
+
* answered as accepted by the member's idempotent redemption, with no second usage row.
|
|
7829
|
+
*
|
|
7830
|
+
* When the invitation grants ownership, the member seats this node's key as an `OwnerKey`
|
|
7831
|
+
* row by consent. The caller still has to wire that key for signing afterwards —
|
|
7832
|
+
* `initializeSeedBootstrap(ownKey)`, which anchors it; never `ensureOwnerKey`, the row is
|
|
7833
|
+
* already there. Other machines derive this node's key into their anchors from the row's
|
|
7834
|
+
* stored proof on their next membership refresh ({@link syncOwnerAnchor}).
|
|
7835
|
+
*
|
|
7836
|
+
* Limit: the reply check ({@link verifyRedeemReply}) does not authenticate the member. A
|
|
7837
|
+
* forged bundle, or a legitimate one whose addresses were swapped, dials a machine that
|
|
7838
|
+
* can echo the owner-signed row it was just sent; that is what pasting an attacker's
|
|
7839
|
+
* invitation means. What protects this node afterwards is its anchor: every row it then
|
|
7840
|
+
* syncs is judged against the pinned owner keys, so a machine that is not a member of the
|
|
7841
|
+
* cadre the invitation names can admit nobody this node will trust.
|
|
7842
|
+
*
|
|
7843
|
+
* @throws `CadreInviteRejectedError` on a final refusal, `CadreInviteReplyInvalidError` on
|
|
7844
|
+
* an acceptance that fails the reply check, `CadreInviteUnreachableError` when every
|
|
7845
|
+
* listed address was tried without an acceptance, and a plain `Error` for the
|
|
7846
|
+
* preconditions above.
|
|
7847
|
+
*/
|
|
7848
|
+
async redeemCadreInvitation(invitation) {
|
|
7849
|
+
const controlNode = this.controlNode;
|
|
7850
|
+
if (!controlNode || !this.controlDatabase || !this._running) {
|
|
7851
|
+
throw new Error('CadreNode must be started before redeeming a cadre invitation');
|
|
7852
|
+
}
|
|
7853
|
+
if (invitation.partyId !== this.config.controlNetwork.partyId) {
|
|
7854
|
+
throw new Error(`Cadre invitation is for party ${invitation.partyId}; this node serves ${this.config.controlNetwork.partyId}`);
|
|
7855
|
+
}
|
|
7856
|
+
const signer = this.getSelfSigningKey();
|
|
7857
|
+
if (!signer) {
|
|
7858
|
+
throw new Error('redeemCadreInvitation: this node runs on an ephemeral identity; configure `keyStore` or `privateKey` so its key can sign the redemption');
|
|
7859
|
+
}
|
|
7860
|
+
await this.trustOwnerKeys(invitation.ownerKeys, 'invite');
|
|
7861
|
+
const pinned = new Set(invitation.ownerKeys);
|
|
7862
|
+
const request = signRedeemRequest(invitation, { peerKey: signer.publicKeyB64, peerPrivateKey: signer.privateKeyB64 }, generateStampId(controlNode.peerId.toString()), await this.resolveInviteAddresses());
|
|
7863
|
+
const { reply, memberPeerId, memberAddr } = await redeemAtMembers(controlNode, {
|
|
7864
|
+
invitation,
|
|
7865
|
+
request,
|
|
7866
|
+
isTrustedIssuer: (ownerKey) => pinned.has(ownerKey),
|
|
7867
|
+
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
7868
|
+
});
|
|
7869
|
+
log('Cadre invitation %s redeemed at %s (owner=%s)', invitation.invite.key, memberAddr, reply.invite.grantsOwner);
|
|
7870
|
+
const selfPeerId = controlNode.peerId.toString();
|
|
7871
|
+
await mergeSeedPeers(controlNode, reply.peers.filter((peer) => peer.peerId !== selfPeerId));
|
|
7872
|
+
if (memberPeerId !== null) {
|
|
7873
|
+
// Every address the bundle gave the answering member, so a restart has the same choices.
|
|
7874
|
+
const memberAddrs = this.parseMultiaddrs(invitation.members)
|
|
7875
|
+
.filter((addr) => trailingPeerId(addr) === memberPeerId)
|
|
7876
|
+
.map((addr) => addr.toString());
|
|
7877
|
+
this.retainDialTarget(memberPeerId, memberAddrs, 'redeemCadreInvitation');
|
|
7878
|
+
}
|
|
7879
|
+
// The pins above flip nothing yet (this node holds no rows), but the rows about to
|
|
7880
|
+
// replicate in are judged against them; refresh so the snapshot is not stale when they land.
|
|
7881
|
+
await this.refreshMembershipGate('invitation-redeemed');
|
|
7882
|
+
void this.reconcileControlCohort().catch((error) => log('redeemCadreInvitation: the post-redemption reconcile pass failed: %o', error));
|
|
7883
|
+
return { peerId: memberPeerId, grantsOwner: reply.invite.grantsOwner, redeemedAt: new Date().toISOString() };
|
|
7262
7884
|
}
|
|
7263
7885
|
// ============================================================================
|
|
7264
7886
|
// Strand Solicitation API (native cadre-core formation transport)
|
|
@@ -7388,10 +8010,12 @@ export class CadreNode {
|
|
|
7388
8010
|
return await this.strandSolicitationService.createOpenInvitation(sAppId, expirationMs, bootstrap);
|
|
7389
8011
|
}
|
|
7390
8012
|
/**
|
|
7391
|
-
* Addresses of the party's other machines for an invitation's bootstrap list
|
|
7392
|
-
* ({@link
|
|
7393
|
-
*
|
|
7394
|
-
* is
|
|
8013
|
+
* Addresses of the party's other machines for an invitation's bootstrap list — an open
|
|
8014
|
+
* strand invitation's ({@link createOpenInvitation}) or a cadre invitation's
|
|
8015
|
+
* ({@link createCadreInvitation}); {@link selectInvitationSiblingAddrs} picks which. The
|
|
8016
|
+
* source is each authorized member's signed `CadrePeer` record ({@link resolvePeerRecord}),
|
|
8017
|
+
* so a machine whose record is missing, stale or untrusted is left out; live connections
|
|
8018
|
+
* only order them.
|
|
7395
8019
|
*
|
|
7396
8020
|
* Best-effort: a failed read leaves the invitation naming this machine alone, which is what
|
|
7397
8021
|
* it named before siblings were added.
|
|
@@ -7413,7 +8037,7 @@ export class CadreNode {
|
|
|
7413
8037
|
return selectInvitationSiblingAddrs(siblings, connected);
|
|
7414
8038
|
}
|
|
7415
8039
|
catch (error) {
|
|
7416
|
-
log('
|
|
8040
|
+
log('siblingInvitationAddrs: resolving the other machines failed; the invitation names only this one: %o', error);
|
|
7417
8041
|
return [];
|
|
7418
8042
|
}
|
|
7419
8043
|
}
|
|
@@ -7450,7 +8074,7 @@ export class CadreNode {
|
|
|
7450
8074
|
* Ask to join through `invitation` and have the party keep trying until the join works, the
|
|
7451
8075
|
* invitation is used up or expires, or the request is dismissed: across restarts, and from
|
|
7452
8076
|
* any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
|
|
7453
|
-
* offline"). Records a party-wide `
|
|
8077
|
+
* offline"). Records a party-wide `JoinRequest` row, runs one attempt on this machine at
|
|
7454
8078
|
* once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
|
|
7455
8079
|
* when it was unreachable or not ready, `'failed'` when it refused for good.
|
|
7456
8080
|
*
|
|
@@ -7516,13 +8140,13 @@ export class CadreNode {
|
|
|
7516
8140
|
return { ownerKey: signingKey.publicKeyB64, signMessage: signMessageWith(signingKey.privateKeyB64) };
|
|
7517
8141
|
}
|
|
7518
8142
|
/**
|
|
7519
|
-
* Write a join request
|
|
7520
|
-
* pending one as it is, a finished one
|
|
8143
|
+
* Write a join request, or adopt the party's existing request for the same invitation: a
|
|
8144
|
+
* pending one as it is, a finished one rewritten as `requested` (the user asked again).
|
|
7521
8145
|
*/
|
|
7522
8146
|
async recordJoinRequest(database, requested, { ownerKey, signMessage }) {
|
|
7523
8147
|
let written;
|
|
7524
8148
|
try {
|
|
7525
|
-
written = await database.
|
|
8149
|
+
written = await database.insertJoinRequest(requested, ownerKey, signMessage);
|
|
7526
8150
|
}
|
|
7527
8151
|
catch (error) {
|
|
7528
8152
|
if (!isPendingJoinConflict(error)) {
|
|
@@ -7533,12 +8157,12 @@ export class CadreNode {
|
|
|
7533
8157
|
throw new Error(`A removed join request for this invitation (${requested.Id}) is still held on this machine until its ` +
|
|
7534
8158
|
'clean-up runs, which needs a connection to the party. Ask again once connected.', { cause: error });
|
|
7535
8159
|
}
|
|
7536
|
-
if (existing.
|
|
8160
|
+
if (existing.outcome === null) {
|
|
7537
8161
|
return existing;
|
|
7538
8162
|
}
|
|
7539
|
-
written = await database.
|
|
8163
|
+
written = await database.rewritePendingJoin(existing.StampId, { ...requested, outcome: null }, ownerKey, signMessage);
|
|
7540
8164
|
}
|
|
7541
|
-
this.pendingJoinRunner?.noteWritten(written);
|
|
8165
|
+
this.pendingJoinRunner?.noteWritten(written, 'request');
|
|
7542
8166
|
return written;
|
|
7543
8167
|
}
|
|
7544
8168
|
/** Build and start the pending-join retry loop over this node (see {@link pendingJoinRunner}). */
|
|
@@ -7554,15 +8178,19 @@ export class CadreNode {
|
|
|
7554
8178
|
selfId: this.controlNode.peerId.toString(),
|
|
7555
8179
|
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
7556
8180
|
isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
|
|
7557
|
-
// NOTE: one owner-key read and one
|
|
7558
|
-
// asked for a join the second reads
|
|
7559
|
-
// shows up in a device profile, poll more slowly while the
|
|
8181
|
+
// NOTE: one owner-key read and one pending-join read (three tables) per pass (30 s); for a
|
|
8182
|
+
// party that never asked for a join the second reads never-written blocks, which consults
|
|
8183
|
+
// the cohort. If it shows up in a device profile, poll more slowly while the tables read empty.
|
|
7560
8184
|
readRows: () => database().queryPendingJoins(),
|
|
7561
8185
|
readRow: (id) => database().queryPendingJoin(id),
|
|
7562
|
-
attempt: (
|
|
7563
|
-
|
|
8186
|
+
attempt: (join) => this.formStrand(this.pendingJoinInvitation(join), parseStoredDisclosure(join)),
|
|
8187
|
+
record: async (expected, outcome) => {
|
|
8188
|
+
const { ownerKey, signMessage } = await outcomeSigner();
|
|
8189
|
+
return database().recordJoinOutcome(expected, outcome, ownerKey, signMessage);
|
|
8190
|
+
},
|
|
8191
|
+
rewrite: async (expectedStampId, next) => {
|
|
7564
8192
|
const { ownerKey, signMessage } = await outcomeSigner();
|
|
7565
|
-
return database().
|
|
8193
|
+
return database().rewritePendingJoin(expectedStampId, next, ownerKey, signMessage);
|
|
7566
8194
|
},
|
|
7567
8195
|
remove: async (id) => {
|
|
7568
8196
|
const { ownerKey, signMessage } = await outcomeSigner();
|
|
@@ -7575,33 +8203,33 @@ export class CadreNode {
|
|
|
7575
8203
|
});
|
|
7576
8204
|
this.pendingJoinRunner.start();
|
|
7577
8205
|
}
|
|
7578
|
-
/** The invitation a `
|
|
7579
|
-
pendingJoinInvitation(
|
|
8206
|
+
/** The invitation a `JoinRequest` row stores. A failure names the row, never the invitation, which is a bearer credential. */
|
|
8207
|
+
pendingJoinInvitation(join) {
|
|
7580
8208
|
try {
|
|
7581
|
-
return this.decodeInvitation(
|
|
8209
|
+
return this.decodeInvitation(join.Invitation);
|
|
7582
8210
|
}
|
|
7583
8211
|
catch {
|
|
7584
|
-
throw new Error(`
|
|
8212
|
+
throw new Error(`JoinRequest ${join.Id}: the stored invitation does not decode`);
|
|
7585
8213
|
}
|
|
7586
8214
|
}
|
|
7587
|
-
pendingJoinSAppId(
|
|
8215
|
+
pendingJoinSAppId(join) {
|
|
7588
8216
|
try {
|
|
7589
|
-
return this.decodeInvitation(
|
|
8217
|
+
return this.decodeInvitation(join.Invitation).sAppId;
|
|
7590
8218
|
}
|
|
7591
8219
|
catch {
|
|
7592
|
-
log('
|
|
8220
|
+
log('JoinRequest %s: the stored invitation does not decode; reporting no sApp', join.Id);
|
|
7593
8221
|
return '';
|
|
7594
8222
|
}
|
|
7595
8223
|
}
|
|
7596
8224
|
/**
|
|
7597
|
-
* Stage the membership invitation of each
|
|
8225
|
+
* Stage the membership invitation of each joined request this process has not staged yet, so a
|
|
7598
8226
|
* closed strand joined on another owner machine is seated by whichever machine launches it
|
|
7599
8227
|
* first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
|
|
7600
8228
|
* seated. A strand that already has an invitation staged keeps it, for the reconciler to
|
|
7601
8229
|
* settle first.
|
|
7602
8230
|
*/
|
|
7603
|
-
stageMembershipInvitesFromPendingJoins(
|
|
7604
|
-
for (const { rowId, strandId, invite } of membershipInvitesToStage(
|
|
8231
|
+
stageMembershipInvitesFromPendingJoins(joins) {
|
|
8232
|
+
for (const { rowId, strandId, invite } of membershipInvitesToStage(joins, this.stagedMembershipInviteKeys, Date.now())) {
|
|
7605
8233
|
this.stagedMembershipInviteKeys.add(invite.inviteKey);
|
|
7606
8234
|
if (this.pendingMembershipInvites.has(strandId)) {
|
|
7607
8235
|
continue;
|