@serfab/cadre-core 1.12.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 +530 -180
- package/dist/cadre-node.js +966 -307
- 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 +196 -157
- package/dist/membership-connection-gater.js +241 -179
- 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-first-sync-gate.d.ts +5 -4
- package/dist/strand-first-sync-gate.js +5 -4
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-protocol.js +1 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +33 -1
- package/dist/strand-instance-manager.js +81 -3
- package/dist/strand-instance-manager.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 +9 -8
- package/src/cadre-invite-protocol.ts +864 -0
- package/src/cadre-node.ts +1043 -317
- 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 +264 -197
- 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-first-sync-gate.ts +392 -391
- package/src/strand-formation-protocol.ts +1 -1
- package/src/strand-instance-manager.ts +1883 -1804
- package/src/strand-revocation-enforcer.ts +11 -11
- package/src/strand-wake-protocol.ts +13 -23
- package/src/trusted-owner-store.ts +242 -171
- package/src/types.ts +2471 -2242
package/src/cadre-node.ts
CHANGED
|
@@ -18,11 +18,10 @@ import type {
|
|
|
18
18
|
CadreNodeEvents,
|
|
19
19
|
ControlNetworkSeed,
|
|
20
20
|
ApplySeedResult,
|
|
21
|
+
SeedAckMessage,
|
|
22
|
+
SeedDeliveryTarget,
|
|
21
23
|
AddDroneOptions,
|
|
22
|
-
AddPhoneOptions,
|
|
23
24
|
DroneInitResult,
|
|
24
|
-
InviteResult,
|
|
25
|
-
CadreInvite,
|
|
26
25
|
OpenInvitation,
|
|
27
26
|
FormStrandResult,
|
|
28
27
|
StrandFormationDisclosure,
|
|
@@ -35,13 +34,18 @@ import type {
|
|
|
35
34
|
DeviceTokenRecord,
|
|
36
35
|
CadrePeerVoucherFields,
|
|
37
36
|
CadrePeerRow,
|
|
37
|
+
InvitationChain,
|
|
38
|
+
CadreInviteStatus,
|
|
39
|
+
CreateCadreInvitationOptions,
|
|
40
|
+
CreateCadreInvitationResult,
|
|
41
|
+
RedeemCadreInvitationResult,
|
|
38
42
|
PeerAddressRecord,
|
|
39
43
|
ResolveDeviceTokenOpts,
|
|
40
|
-
|
|
44
|
+
PendingJoin,
|
|
41
45
|
PendingJoinStatus
|
|
42
46
|
} from './types.js';
|
|
43
47
|
import { controlClusterPolicy, CONTROL_REPLICATION_BREADTH, DEFAULT_CHECKIN_WINDOW_MS, DEFAULT_CONNECTION_MONITOR } from './types.js';
|
|
44
|
-
import { sign } from '@optimystic/quereus-plugin-crypto';
|
|
48
|
+
import { generatePrivateKey, sign } from '@optimystic/quereus-plugin-crypto';
|
|
45
49
|
import { ed25519KeyPairFromLibp2p, ed25519PublicKeyFromPrivate, requireEd25519PublicKeyB64, type Ed25519KeyPair } from './ed25519-key.js';
|
|
46
50
|
import { strandTransportKey } from './strand-transport-key.js';
|
|
47
51
|
import {
|
|
@@ -69,8 +73,9 @@ import {
|
|
|
69
73
|
} from './joined-strand-store.js';
|
|
70
74
|
import { mergePeerAddrs, groupAddrsByPeerId, type MergeAddrsResult } from './peer-addr-book.js';
|
|
71
75
|
import { strandFretPeerAddrs } from './strand-fret-addrs.js';
|
|
72
|
-
import { verifyCadrePeerVoucher } from './peer-authorization.js';
|
|
73
|
-
import {
|
|
76
|
+
import { verifyCadrePeerVoucher, verifyInvitationAdmission } from './peer-authorization.js';
|
|
77
|
+
import { deriveOwnerAnchor } from './owner-anchor-sync.js';
|
|
78
|
+
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
74
79
|
import {
|
|
75
80
|
signPeerRecord,
|
|
76
81
|
verifyPeerRecordSignature,
|
|
@@ -105,21 +110,28 @@ import {
|
|
|
105
110
|
CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS,
|
|
106
111
|
type PeerDialBudget
|
|
107
112
|
} from './peer-dial.js';
|
|
108
|
-
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget,
|
|
113
|
+
import { ADMISSION_DECISION_TIMEOUT_MS, declaredCohortReadDeadlineMs, optimysticDialLimits, peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, relayedRequestBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
109
114
|
import { EnrollmentService } from './enrollment.js';
|
|
110
115
|
import { HibernationManager, type HibernationCallbacks } from './hibernation-manager.js';
|
|
111
|
-
import { ControlDatabase, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type RevokedRowRef } from './control-database.js';
|
|
116
|
+
import { ControlDatabase, generateStampId, isPendingJoinConflict, isStrandIdConflict, pendingJoinId, type JoinRequestFields, type RevokedRowRef } from './control-database.js';
|
|
117
|
+
import {
|
|
118
|
+
CadreInviteHandler,
|
|
119
|
+
encodeCadreInvitation,
|
|
120
|
+
redeemAtMembers,
|
|
121
|
+
signRedeemRequest,
|
|
122
|
+
type CadreInvitation
|
|
123
|
+
} from './cadre-invite-protocol.js';
|
|
112
124
|
import { FormationPostApprovalError } from './strand-formation-rejection.js';
|
|
113
125
|
import {
|
|
114
126
|
PendingJoinRunner,
|
|
115
127
|
membershipInvitesToStage,
|
|
116
128
|
parseStoredDisclosure,
|
|
117
|
-
requestedPendingJoin
|
|
118
|
-
type PendingJoinFields
|
|
129
|
+
requestedPendingJoin
|
|
119
130
|
} from './pending-join-runner.js';
|
|
120
131
|
import type { ControlRetryAbandonment } from './control-retry.js';
|
|
121
|
-
import { SeedBootstrapService, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
|
|
122
|
-
import type
|
|
132
|
+
import { SeedBootstrapService, mergeSeedPeers, seedDigest, type SeedEventCallbacks, type SeedBootstrapConfig } from './seed-bootstrap.js';
|
|
133
|
+
import { claimSecretTrustPolicy, type SeedTrustPolicy } from './seed-trust-policy.js';
|
|
134
|
+
import { ClaimRefusedError, claimProof, parseClaimSecret } from './claim-proof.js';
|
|
123
135
|
import {
|
|
124
136
|
StrandSolicitationService,
|
|
125
137
|
type StrandSolicitationServiceOptions
|
|
@@ -128,10 +140,10 @@ import { ControlFormationUsageRecorder } from './control-formation-recorder.js';
|
|
|
128
140
|
import { selectInvitationSiblingAddrs, type InvitationSibling } from './invitation-bootstrap.js';
|
|
129
141
|
import {
|
|
130
142
|
createMembershipConnectionGater,
|
|
131
|
-
DEFAULT_ENROLLMENT_WINDOW_MS,
|
|
132
143
|
UnauthorizedReservationBudget,
|
|
133
144
|
type InboundConnectionVerdict
|
|
134
145
|
} from './membership-connection-gater.js';
|
|
146
|
+
import { controlNetworkName, installControlProtocolGuard } from './control-protocol-guard.js';
|
|
135
147
|
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
136
148
|
import { StrandAddrService, collectStrandAddrs, type StrandAddrPeer, type StrandAddrOutcome, type StrandAddrCollection } from './strand-addr-protocol.js';
|
|
137
149
|
import {
|
|
@@ -187,6 +199,17 @@ export const STRAND_PEER_ADDR_REFRESH_MS = 10 * 60 * 1000;
|
|
|
187
199
|
*/
|
|
188
200
|
export const STRAND_PEER_ADDR_RETRY_MS = 60 * 1000;
|
|
189
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Default lifetime of an UNTARGETED, OWNER-GRANTING cadre invitation
|
|
204
|
+
* ({@link CadreNode.createCadreInvitation}): whoever holds the bundle becomes an owner, so
|
|
205
|
+
* it is a bearer credential for admin rights and lives only long enough to be pasted or
|
|
206
|
+
* scanned by the device it was minted for.
|
|
207
|
+
*/
|
|
208
|
+
export const OWNER_INVITATION_DEFAULT_TTL_MS = 15 * 60 * 1000;
|
|
209
|
+
|
|
210
|
+
/** Default lifetime of every other cadre invitation: targeted, or granting membership only. */
|
|
211
|
+
export const CADRE_INVITATION_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
212
|
+
|
|
190
213
|
type EventHandler<T> = (data: T) => void;
|
|
191
214
|
|
|
192
215
|
/**
|
|
@@ -235,6 +258,18 @@ function unionAddrs(primary: readonly string[], extra: readonly string[]): strin
|
|
|
235
258
|
* canonical row-bound message BYTES (see `buildAuthorizationMessage`), and it ed25519-signs
|
|
236
259
|
* them directly (no pre-hash) with the owner private key, returning a base64url signature.
|
|
237
260
|
*/
|
|
261
|
+
/**
|
|
262
|
+
* One membership refresh's invitation chain ({@link InvitationChain}), loaded at most once
|
|
263
|
+
* and shared by the anchor sync (`OwnerKey` rows) and the membership read (`CadrePeer`
|
|
264
|
+
* rows), each of which calls it only when some row it judges is invitation-admitted.
|
|
265
|
+
*/
|
|
266
|
+
type InvitationChainSource = () => Promise<InvitationChain>;
|
|
267
|
+
|
|
268
|
+
/** Whether a `CadrePeer` or `OwnerKey` row's proof is an invitation admission rather than an owner voucher (`types.ts` → `CadrePeerRow`, `OwnerKeyRow`). */
|
|
269
|
+
function isInvitationAdmitted(row: { vouchSig: string | null; vouchUsage: string | null }): boolean {
|
|
270
|
+
return row.vouchSig === null && row.vouchUsage !== null;
|
|
271
|
+
}
|
|
272
|
+
|
|
238
273
|
function signMessageWith(privateKeyB64: string): (message: Uint8Array) => string {
|
|
239
274
|
return (message: Uint8Array): string =>
|
|
240
275
|
sign(message, privateKeyB64, 'ed25519', 'bytes', 'base64url', 'base64url') as string;
|
|
@@ -374,11 +409,25 @@ export class CadreNode implements SAppIdLookup {
|
|
|
374
409
|
* start() cycle rebuilds it with a fresh caught-up-peer memo.
|
|
375
410
|
*/
|
|
376
411
|
private controlBackfill: PeerJoinBackfill | null = null;
|
|
412
|
+
/**
|
|
413
|
+
* Devices whose invitation redemption this member has verified and is admitting right now,
|
|
414
|
+
* each with its count of overlapping redemptions. The control backfill's gate admits them
|
|
415
|
+
* as it admits a member, for that window only ({@link catchUpRedeemingDevice}).
|
|
416
|
+
*/
|
|
417
|
+
private readonly admittingDevices = new Map<string, number>();
|
|
377
418
|
private strandWatcher: StrandWatcher | null = null;
|
|
378
419
|
private strandManager: StrandInstanceManager;
|
|
379
420
|
private hibernationManager: HibernationManager;
|
|
380
421
|
private enrollmentService: EnrollmentService;
|
|
381
422
|
private seedBootstrapService: SeedBootstrapService | null = null;
|
|
423
|
+
/**
|
|
424
|
+
* The claim-secret trust policy of a node started with `config.claim`, built once by
|
|
425
|
+
* {@link initializeClaimPolicy} over {@link trustedOwnerStore} and handed to every
|
|
426
|
+
* seed service this node constructs ({@link inboundSeedTrustPolicy}). Kept across
|
|
427
|
+
* stop()→start() like the anchor it writes: its in-process latch and failure window
|
|
428
|
+
* are per node, not per seed service. Null when the node has no claim secret.
|
|
429
|
+
*/
|
|
430
|
+
private claimTrustPolicy: SeedTrustPolicy | null = null;
|
|
382
431
|
private strandSolicitationService: StrandSolicitationService | null = null;
|
|
383
432
|
/**
|
|
384
433
|
* The service whose formation handler is registered on the control node. Trails
|
|
@@ -397,6 +446,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
397
446
|
* stops conflating control addresses with strand seeding.
|
|
398
447
|
*/
|
|
399
448
|
private strandAddrService: StrandAddrService | null = null;
|
|
449
|
+
/**
|
|
450
|
+
* Cadre invitation redemption handler (`/sereus/cadre-invite/1.0.0`): a device holding an
|
|
451
|
+
* invitation dials this machine and proves possession; the handler seats the row and
|
|
452
|
+
* admits the device against this node's control database. Registered by {@link start}
|
|
453
|
+
* once that database is up, on every node.
|
|
454
|
+
*/
|
|
455
|
+
private cadreInviteHandler: CadreInviteHandler | null = null;
|
|
400
456
|
/**
|
|
401
457
|
* Server-side push-wake fan-out. Constructed by {@link start} only when
|
|
402
458
|
* `config.push` (an injected `PushNotifier` + policy) is present — without it
|
|
@@ -508,26 +564,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
508
564
|
/**
|
|
509
565
|
* Most-recently pushed invite addresses (see {@link setInviteAddresses}).
|
|
510
566
|
* When non-null these take priority over `libp2pNode.getMultiaddrs()` when
|
|
511
|
-
* minting
|
|
512
|
-
* control-network node never needs to dial back to the manager.
|
|
567
|
+
* minting cadre invitations — the host pushes NAT-resolved addresses here so
|
|
568
|
+
* the control-network node never needs to dial back to the manager.
|
|
513
569
|
*/
|
|
514
570
|
private latestInviteAddresses: string[] | null = null;
|
|
515
571
|
|
|
516
|
-
/**
|
|
517
|
-
* Epoch ms until which the control-network inbound gate admits
|
|
518
|
-
* not-yet-authorized peers (see {@link admitInboundControlConnection} /
|
|
519
|
-
* {@link openEnrollmentWindow}). 0 = no window open. Opened automatically by
|
|
520
|
-
* {@link createInvite}; deliberately NOT reset on stop() — a stop/start cycle
|
|
521
|
-
* inside an outstanding invite's validity must not strand the invitee.
|
|
522
|
-
*
|
|
523
|
-
* NOTE: in-memory only, so a PROCESS restart mid-invite closes the door until
|
|
524
|
-
* the owner re-mints or the invitee is authorized. Fine while invites are
|
|
525
|
-
* short-lived and redeemed promptly; if long-lived invites or restart-prone
|
|
526
|
-
* hosts become normal, persist the window (or derive it from the issued-invite
|
|
527
|
-
* records `SeedBootstrapService` already keeps).
|
|
528
|
-
*/
|
|
529
|
-
private enrollmentWindowUntil = 0;
|
|
530
|
-
|
|
531
572
|
/**
|
|
532
573
|
* Lazily-parsed PeerIds of `controlNetwork.bootstrapNodes` (see
|
|
533
574
|
* {@link getBootstrapPeerIds}) — infrastructure the inbound gate always admits.
|
|
@@ -594,10 +635,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
594
635
|
* Does this node's CONTROL libp2p run the circuit-relay server, and with which
|
|
595
636
|
* init? Resolved once from `network` and `profile` by the same function every
|
|
596
637
|
* strand node's build uses (`relay-server.ts`). Read by
|
|
597
|
-
* {@link buildControlNodeOptions} (which configures the server from it)
|
|
598
|
-
*
|
|
599
|
-
* agree with whether a reservation is even servable here), and the
|
|
600
|
-
* unauthorized budget above (whose TTL is the server's).
|
|
638
|
+
* {@link buildControlNodeOptions} (which configures the server from it) and
|
|
639
|
+
* the unauthorized budget above (whose TTL is the server's).
|
|
601
640
|
*/
|
|
602
641
|
private readonly relayServer: ResolvedRelayServer;
|
|
603
642
|
|
|
@@ -709,7 +748,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
709
748
|
|
|
710
749
|
/**
|
|
711
750
|
* Invite keys of the membership invitations this process has staged, by
|
|
712
|
-
* {@link adoptFormationMembershipInvite} or from a `
|
|
751
|
+
* {@link adoptFormationMembershipInvite} or from a `JoinSuccess` row
|
|
713
752
|
* ({@link stageMembershipInvitesFromPendingJoins}), so one the reconciler has settled is not
|
|
714
753
|
* staged again by the next pending-join pass. Same lifetime as
|
|
715
754
|
* {@link pendingMembershipInvites}: a restarted process stages each live one once more, and the
|
|
@@ -1108,12 +1147,22 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1108
1147
|
// store fails closed before a node is created.
|
|
1109
1148
|
await this.resolveIdentityKey();
|
|
1110
1149
|
|
|
1150
|
+
// A conflicting `claim` is refused BEFORE the anchor is built: the store below
|
|
1151
|
+
// writes the config pins, and on a persistent anchor a pin written ahead of
|
|
1152
|
+
// this refusal would survive the failed start and mark the node claimed by it.
|
|
1153
|
+
this.validateClaimConfig();
|
|
1154
|
+
|
|
1111
1155
|
// Bring up the node-local trusted-owner anchor (and seed config pins)
|
|
1112
1156
|
// before any network bring-up: a mis-scoped injected store fails closed
|
|
1113
1157
|
// here, and out-of-band pins are anchored before the first seed/peer
|
|
1114
1158
|
// interaction could consult them.
|
|
1115
1159
|
await this.initializeTrustedOwnerStore();
|
|
1116
1160
|
|
|
1161
|
+
// A node started with a claim secret gets its trust policy here, over the anchor
|
|
1162
|
+
// just built; a malformed secret fails closed before any network bring-up, like
|
|
1163
|
+
// a mis-scoped anchor above.
|
|
1164
|
+
this.initializeClaimPolicy();
|
|
1165
|
+
|
|
1117
1166
|
// Bring up the node-local cold-start bootstrap-peer store alongside the
|
|
1118
1167
|
// anchor, and for the same two reasons: a mis-scoped injected store fails
|
|
1119
1168
|
// closed before any network bring-up, and the retained dial targets are
|
|
@@ -1138,22 +1187,20 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1138
1187
|
this.initializeEnrolledMachineStore();
|
|
1139
1188
|
|
|
1140
1189
|
// Arm the connection gate's BRING-UP QUIET PERIOD before the libp2p node
|
|
1141
|
-
// exists, so no connection can form ahead of it:
|
|
1142
|
-
// its
|
|
1143
|
-
//
|
|
1190
|
+
// exists, so no connection can form ahead of it: the node is built without
|
|
1191
|
+
// its bootstrap peers (dialed after bring-up), but a peer it already knows
|
|
1192
|
+
// from a previous run, or one that dials in, could otherwise connect inside
|
|
1193
|
+
// createControlNode below.
|
|
1144
1194
|
// The invariant being protected is "zero control connections during
|
|
1145
1195
|
// control-database bring-up" — see the field's doc and
|
|
1146
1196
|
// `membership-connection-gater.ts` → "The bring-up quiet period".
|
|
1147
1197
|
//
|
|
1148
|
-
// NOTE:
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
// range bring-up is 9-15 s, far past the fuse. So if this gate is ever removed
|
|
1155
|
-
// or bypassed, that arm reopens for exactly the nodes most likely to hit it —
|
|
1156
|
-
// phones joining a party, whose membership row has not replicated yet.
|
|
1198
|
+
// NOTE: the gate, not the ordering, is what holds the invariant. Bring-up
|
|
1199
|
+
// duration is (raw-storage operations) × per-operation latency; the cold-start
|
|
1200
|
+
// operation count and the 50-90 ms/op figure a loaded phone sees live in
|
|
1201
|
+
// `control-database.ts`'s `loadSchema` note (pinned by
|
|
1202
|
+
// `control-start-storage-op-budget.spec.ts`), so bring-up can take 9-15 s —
|
|
1203
|
+
// ample time for a remembered or inbound peer to connect without the gate.
|
|
1157
1204
|
this.controlBringUpInFlight = true;
|
|
1158
1205
|
|
|
1159
1206
|
// Create the control network libp2p node
|
|
@@ -1161,6 +1208,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1161
1208
|
this.controlNode = await this.createControlNode();
|
|
1162
1209
|
timing('[start] createControlNode: %dms', Math.round(performance.now() - t0));
|
|
1163
1210
|
log('Control node started with ID: %s', this.controlNode.peerId.toString());
|
|
1211
|
+
// Before anything else can happen on the node: the quiet period above holds every
|
|
1212
|
+
// connection off, so no stream can arrive ahead of the guard.
|
|
1213
|
+
this.installControlProtocolGuard(this.controlNode);
|
|
1164
1214
|
|
|
1165
1215
|
// Extract coordinatedRepo from the node (attached by createLibp2pNode)
|
|
1166
1216
|
const coordinatedRepo = (this.controlNode as Libp2pNodeWithRepo).coordinatedRepo;
|
|
@@ -1194,6 +1244,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1194
1244
|
// bring-up hazard for boot-time strand seeds that are always empty.
|
|
1195
1245
|
this.controlBringUpInFlight = false;
|
|
1196
1246
|
log('Control database initialized');
|
|
1247
|
+
// Now that the gate is open, make contact with the configured bootstrap peers the
|
|
1248
|
+
// node was built without (see buildControlNodeOptions). Not awaited: an unreachable
|
|
1249
|
+
// relay must not hold up start().
|
|
1250
|
+
void this.dialControlBootstrapPeers();
|
|
1197
1251
|
|
|
1198
1252
|
// Attach the per-stream gate to the control DB's membership hub, so every
|
|
1199
1253
|
// committed `CadrePeer` write re-materializes the authorized snapshot on its
|
|
@@ -1232,11 +1286,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1232
1286
|
this.hibernationManager.start();
|
|
1233
1287
|
|
|
1234
1288
|
// Register the control-network push-wake receiver: a same-cadre peer can
|
|
1235
|
-
// signal us to bring a hibernating strand online.
|
|
1236
|
-
//
|
|
1237
|
-
// path as a local wake.
|
|
1289
|
+
// signal us to bring a hibernating strand online. The protocol guard admits
|
|
1290
|
+
// only AUTHORIZED members (not the addressable surface); the wake routes
|
|
1291
|
+
// through the same path as a local wake.
|
|
1238
1292
|
this.strandWakeService = new StrandWakeService({
|
|
1239
|
-
isMember: (peerId) => this.isAuthorizedMember(peerId),
|
|
1240
1293
|
getStrand: (strandId) => this.strandManager.getInstance(strandId),
|
|
1241
1294
|
wake: (strandId) => this.wakeStrand(strandId),
|
|
1242
1295
|
});
|
|
@@ -1244,14 +1297,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1244
1297
|
|
|
1245
1298
|
// Register the control-network strand-address responder: a same-cadre peer
|
|
1246
1299
|
// resolving a strand's bootstrap seed asks us for our live strand-network
|
|
1247
|
-
// multiaddrs (its CadrePeer row only knows our *control* address).
|
|
1248
|
-
// AUTHORIZED
|
|
1249
|
-
// The same RPC doubles as the delegate-announce channel:
|
|
1250
|
-
// may carry the derived peerId its strand node runs as, and
|
|
1251
|
-
// admission grant so our connection gate (and thus our relay
|
|
1252
|
-
// enabled) admits it — see delegate-admission.ts.
|
|
1300
|
+
// multiaddrs (its CadrePeer row only knows our *control* address). The
|
|
1301
|
+
// protocol guard admits only AUTHORIZED members; answers only for strands we
|
|
1302
|
+
// are actively meshing. The same RPC doubles as the delegate-announce channel:
|
|
1303
|
+
// a member's request may carry the derived peerId its strand node runs as, and
|
|
1304
|
+
// we record an admission grant so our connection gate (and thus our relay
|
|
1305
|
+
// server, when enabled) admits it — see delegate-admission.ts.
|
|
1253
1306
|
this.strandAddrService = new StrandAddrService({
|
|
1254
|
-
isMember: (peerId) => this.isAuthorizedMember(peerId),
|
|
1255
1307
|
getStrandMultiaddrs: (strandId) => this.getStrandMultiaddrs(strandId),
|
|
1256
1308
|
onDelegateAnnounce: (announcer, strandId, delegate) =>
|
|
1257
1309
|
this.grantDelegateAdmission(announcer, strandId, delegate),
|
|
@@ -1264,6 +1316,24 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1264
1316
|
// so the responder never reads a database that cannot answer yet.
|
|
1265
1317
|
await this.installDefaultFormationResponder();
|
|
1266
1318
|
|
|
1319
|
+
// Answer cadre invitation redemptions from now on: a device holding an invitation
|
|
1320
|
+
// dials any member machine, this one included, and proves possession in-protocol.
|
|
1321
|
+
// After the control database is up, since the handler seats and redeems against it.
|
|
1322
|
+
this.cadreInviteHandler = new CadreInviteHandler({
|
|
1323
|
+
partyId: this.config.controlNetwork.partyId,
|
|
1324
|
+
store: this.controlDatabase,
|
|
1325
|
+
catchUpDevice: (peerId) => this.catchUpRedeemingDevice(peerId),
|
|
1326
|
+
});
|
|
1327
|
+
await this.cadreInviteHandler.register(this.controlNode);
|
|
1328
|
+
|
|
1329
|
+
// A node waiting to be claimed listens for its claim seed from the start: nobody
|
|
1330
|
+
// else will turn the listener on, and a node no seed can reach cannot be claimed.
|
|
1331
|
+
// Before `control:connected`, so an embedder that reports "awaiting claim" on that
|
|
1332
|
+
// event is already reachable on `/sereus/seed/1.0.0`.
|
|
1333
|
+
if (this.config.claim) {
|
|
1334
|
+
await this.enableSeedListener();
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1267
1337
|
// Server-side push-wake fan-out: only when push is configured.
|
|
1268
1338
|
if (this.config.push) {
|
|
1269
1339
|
this.pushFanoutService = this.buildPushFanout(this.config.push);
|
|
@@ -1444,15 +1514,22 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1444
1514
|
|
|
1445
1515
|
/**
|
|
1446
1516
|
* Construct (or adopt) the node-local trusted-owner anchor and seed the
|
|
1447
|
-
* out-of-band pinned keys from `config.trustedOwners`. The
|
|
1448
|
-
*
|
|
1449
|
-
*
|
|
1450
|
-
*
|
|
1517
|
+
* out-of-band pinned keys from `config.trustedOwners`. The anchor's BASE comes
|
|
1518
|
+
* only from out of band — genesis self-trust ({@link initializeSeedBootstrap}),
|
|
1519
|
+
* config pins (here), runtime enrollment pins ({@link trustOwnerKeys}) and a
|
|
1520
|
+
* claim; everything else in it is derived from that base along verifiable
|
|
1521
|
+
* chains through the replicated `OwnerKey` table ({@link syncOwnerAnchor}),
|
|
1522
|
+
* never read from the table directly.
|
|
1451
1523
|
*
|
|
1452
1524
|
* Idempotent across stop()→start(): the store instance is kept, and
|
|
1453
1525
|
* re-seeding config pins is a no-op ({@link TrustedOwnerStore.trust} is
|
|
1454
1526
|
* idempotent). An injected store scoped to a different party is a
|
|
1455
1527
|
* configuration error (fail closed before any network bring-up).
|
|
1528
|
+
*
|
|
1529
|
+
* NOTE: a config pin of a key the owners have since REMOVED is re-anchored here at
|
|
1530
|
+
* every start and removed again by the first sync (`refreshMembershipGate('start')`,
|
|
1531
|
+
* fired without await from {@link start}), so such a pin is honoured for at most one
|
|
1532
|
+
* membership refresh per start; dropping the pin from the config ends that.
|
|
1456
1533
|
*/
|
|
1457
1534
|
private async initializeTrustedOwnerStore(): Promise<void> {
|
|
1458
1535
|
const { trustedOwners } = this.config;
|
|
@@ -1476,6 +1553,83 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1476
1553
|
}
|
|
1477
1554
|
}
|
|
1478
1555
|
|
|
1556
|
+
/**
|
|
1557
|
+
* Refuse a `config.claim` that sits beside another cold-start trust source. A node
|
|
1558
|
+
* has exactly ONE, so `claim` with a non-empty `trustedOwners.pinnedKeys` or a
|
|
1559
|
+
* `seedTrustPolicy` is refused here rather than silently letting one of them win.
|
|
1560
|
+
* Asserted at this seam, not by a type: the embedders build `CadreNodeConfig` as one
|
|
1561
|
+
* object literal with every optional field present, which a discriminated union over
|
|
1562
|
+
* the whole config would not express.
|
|
1563
|
+
*
|
|
1564
|
+
* Runs BEFORE {@link initializeTrustedOwnerStore}, which writes the config pins into
|
|
1565
|
+
* the anchor: on a persistent anchor a pin written ahead of this refusal would outlive
|
|
1566
|
+
* the failed start, and the node, restarted with the pin dropped from its config and
|
|
1567
|
+
* `claim` kept, would read as claimed by that pin and never accept its claimant.
|
|
1568
|
+
*/
|
|
1569
|
+
private validateClaimConfig(): void {
|
|
1570
|
+
if (!this.config.claim) {
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
if ((this.config.trustedOwners?.pinnedKeys?.length ?? 0) > 0) {
|
|
1574
|
+
throw new Error(
|
|
1575
|
+
'CadreNodeConfig: `claim` and `trustedOwners.pinnedKeys` are both set — a node has one ' +
|
|
1576
|
+
'cold-start trust source; a node waiting to be claimed anchors its claimant, not a pin'
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
if (this.config.seedTrustPolicy) {
|
|
1580
|
+
throw new Error(
|
|
1581
|
+
'CadreNodeConfig: `claim` and `seedTrustPolicy` are both set — a node has one cold-start ' +
|
|
1582
|
+
'trust source; the claim secret supplies the seed trust policy itself'
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/**
|
|
1588
|
+
* Build {@link claimTrustPolicy} from `config.claim`, once per node (a restart keeps
|
|
1589
|
+
* the policy, as {@link initializeTrustedOwnerStore} keeps the anchor it writes).
|
|
1590
|
+
* Runs after the anchor exists and before any network bring-up, so a malformed secret
|
|
1591
|
+
* fails `start()` closed with nothing to tear down; `parseClaimSecret` names the
|
|
1592
|
+
* problem and never echoes the value. The conflicts with the other trust sources are
|
|
1593
|
+
* {@link validateClaimConfig}'s, checked before the anchor is written.
|
|
1594
|
+
*/
|
|
1595
|
+
private initializeClaimPolicy(): void {
|
|
1596
|
+
const { claim } = this.config;
|
|
1597
|
+
if (!claim || this.claimTrustPolicy) {
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
// Unreachable in practice: start() builds the store just above. Guarded so the
|
|
1601
|
+
// policy can never be built over a missing anchor and silently claim into nothing.
|
|
1602
|
+
if (!this.trustedOwnerStore) {
|
|
1603
|
+
throw new Error('CadreNode: the trusted-owner anchor must exist before the claim policy is built');
|
|
1604
|
+
}
|
|
1605
|
+
this.claimTrustPolicy = claimSecretTrustPolicy({
|
|
1606
|
+
secret: parseClaimSecret(claim.secret),
|
|
1607
|
+
trustedOwners: this.trustedOwnerStore,
|
|
1608
|
+
recordClaim: claim.record,
|
|
1609
|
+
onClaimed: (accepted) => this.emit('claim:accepted', accepted),
|
|
1610
|
+
});
|
|
1611
|
+
log('Node starts awaiting a claim; start() enables the seed listener once the control node is up');
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* Is this node waiting to be claimed: started with `config.claim` and owned by nobody
|
|
1616
|
+
* yet (empty anchor)? Read live from the anchor every time, as
|
|
1617
|
+
* {@link admitControlPeerUnconditionally} reads it per stream: the anchor is the one
|
|
1618
|
+
* source of truth for "claimed", and a cached flag would need its own invalidation
|
|
1619
|
+
* from the policy's persist, the rollback and a restart. False on a node with no claim
|
|
1620
|
+
* secret, on a claimed one (restarted with the secret still set or not), and before
|
|
1621
|
+
* {@link start} has built the anchor.
|
|
1622
|
+
*
|
|
1623
|
+
* While true the node admits strangers' CONNECTIONS (the claim seed has to ride one)
|
|
1624
|
+
* and refuses their control-database streams ({@link authorizeInboundControlStream})
|
|
1625
|
+
* and relay reservations ({@link admitControlRelayReservation}): an unclaimed node has
|
|
1626
|
+
* no siblings to replicate from and must not become a public relay while it waits.
|
|
1627
|
+
* cadre-cli reports this on `/status`; `claim:accepted` is the event form.
|
|
1628
|
+
*/
|
|
1629
|
+
isAwaitingClaim(): boolean {
|
|
1630
|
+
return this.config.claim !== undefined && this.trustedOwnerStore?.all().size === 0;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1479
1633
|
/**
|
|
1480
1634
|
* Construct (or adopt) the node-local cold-start bootstrap-peer store (see
|
|
1481
1635
|
* {@link bootstrapPeerStore}). Synchronous: an injected store has already
|
|
@@ -1626,22 +1780,23 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1626
1780
|
|
|
1627
1781
|
/**
|
|
1628
1782
|
* Persist out-of-band-established owner keys into the node-local anchor —
|
|
1629
|
-
* the runtime enrollment seam:
|
|
1630
|
-
*
|
|
1631
|
-
* holds the pins when
|
|
1632
|
-
* pin. Idempotent. ('genesis'
|
|
1633
|
-
* founding key, seeded internally by
|
|
1783
|
+
* the runtime enrollment seam: {@link redeemCadreInvitation} calls it with the
|
|
1784
|
+
* cadre invitation's `ownerKeys` BEFORE dialing a member (so the anchor already
|
|
1785
|
+
* holds the pins when the reply and the rows that follow are judged), and an
|
|
1786
|
+
* embedder calls it with an operator-supplied pin. Idempotent. ('genesis'
|
|
1787
|
+
* provenance is reserved for the node's own founding key, seeded internally by
|
|
1788
|
+
* {@link initializeSeedBootstrap}; 'chain' is written only by {@link syncOwnerAnchor}.)
|
|
1634
1789
|
*
|
|
1635
1790
|
* Validates every key's shape before trusting any of them (all-or-nothing):
|
|
1636
1791
|
* a malformed entry anywhere in `keys` rejects the whole call before a
|
|
1637
|
-
* single key is anchored. For the
|
|
1792
|
+
* single key is anchored. For the invitation route this means a bundle
|
|
1638
1793
|
* carrying one malformed `ownerKeys` entry fails the redemption outright —
|
|
1639
1794
|
* consistent with the anchor's existing whole-record-or-nothing policy for
|
|
1640
1795
|
* a corrupt persisted entry (see `trusted-owner-store.ts`'s
|
|
1641
1796
|
* `unusableEntry: 'discard-all'`) — rather than silently anchoring a subset
|
|
1642
1797
|
* and leaving the caller to notice a key went missing.
|
|
1643
1798
|
*/
|
|
1644
|
-
async trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis'>): Promise<void> {
|
|
1799
|
+
async trustOwnerKeys(keys: Iterable<string>, source: Exclude<TrustSource, 'genesis' | 'chain'>): Promise<void> {
|
|
1645
1800
|
if (!this.trustedOwnerStore) {
|
|
1646
1801
|
throw new Error('CadreNode must be started before trusting owner keys');
|
|
1647
1802
|
}
|
|
@@ -1656,19 +1811,24 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1656
1811
|
}
|
|
1657
1812
|
|
|
1658
1813
|
/**
|
|
1659
|
-
*
|
|
1660
|
-
*
|
|
1661
|
-
*
|
|
1662
|
-
*
|
|
1663
|
-
*
|
|
1664
|
-
* NOTE: the party id goes in UNENCODED here, unlike in `controlStorageScope`. Safe
|
|
1665
|
-
* today because a party id is locally configured rather than replicated in, and both
|
|
1666
|
-
* ends of a connection derive this string identically — an odd party id yields an odd
|
|
1667
|
-
* but consistent protocol id, not a mismatch or an escaped name. If a party id ever
|
|
1668
|
-
* arrives from the network, encode it here as the storage scope key already does.
|
|
1814
|
+
* Refuse non-members on every members-only protocol of the control node, at one seam
|
|
1815
|
+
* (`control-protocol-guard.ts`). Wake and strand-addr are judged by the live
|
|
1816
|
+
* {@link isAuthorizedMember}, bounded by the asker's own attempt deadline at this
|
|
1817
|
+
* node's declared link; everything a control-database read depends on is judged by
|
|
1818
|
+
* the snapshot ({@link authorizeInboundControlStream}).
|
|
1669
1819
|
*/
|
|
1820
|
+
private installControlProtocolGuard(controlNode: Libp2p): void {
|
|
1821
|
+
installControlProtocolGuard(controlNode, {
|
|
1822
|
+
partyId: this.config.controlNetwork.partyId,
|
|
1823
|
+
isMemberLive: (peerId) => this.isAuthorizedMember(peerId),
|
|
1824
|
+
isMemberSnapshot: (peerId, protocol) => this.authorizeInboundControlStream(peerId, protocol),
|
|
1825
|
+
liveDecisionTimeoutMs: relayedRequestBudgetMs(this.config.network?.linkRoundTripMs),
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/** This node's control network name ({@link controlNetworkName}, the one binding for it). */
|
|
1670
1830
|
private controlNetworkName(): string {
|
|
1671
|
-
return
|
|
1831
|
+
return controlNetworkName(this.config.controlNetwork.partyId);
|
|
1672
1832
|
}
|
|
1673
1833
|
|
|
1674
1834
|
/**
|
|
@@ -1696,6 +1856,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1696
1856
|
* reconnect re-runs identify), and — because the production join order is
|
|
1697
1857
|
* connect-then-authorize — on every committed membership change via
|
|
1698
1858
|
* {@link refreshAuthorizedControlPeers}'s `scheduleConnectedPeers()` call.
|
|
1859
|
+
* The one non-member the gate admits is a device whose invitation
|
|
1860
|
+
* redemption this member is admitting ({@link catchUpRedeemingDevice}).
|
|
1699
1861
|
*
|
|
1700
1862
|
* No-ops (logged) when the embedder configured no control storage or the
|
|
1701
1863
|
* node exposes no key network — the backfill would have nothing to read or
|
|
@@ -1726,7 +1888,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1726
1888
|
// under — derived from the same networkName binding the node options
|
|
1727
1889
|
// used, never re-spelled here.
|
|
1728
1890
|
protocolPrefix: `/optimystic/${networkName}`,
|
|
1729
|
-
authorizePeer: (peerId) => this.isAuthorizedMember(peerId)
|
|
1891
|
+
authorizePeer: async (peerId) => this.admittingDevices.has(peerId) || await this.isAuthorizedMember(peerId)
|
|
1730
1892
|
}, {
|
|
1731
1893
|
// A shorter settle window than the strand default (1000 ms): the control
|
|
1732
1894
|
// store is small (a party's membership — dozens of blocks), a re-push is
|
|
@@ -1753,6 +1915,39 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1753
1915
|
this.controlBackfill.start();
|
|
1754
1916
|
}
|
|
1755
1917
|
|
|
1918
|
+
/**
|
|
1919
|
+
* Push this member's control store to a device whose invitation redemption has verified,
|
|
1920
|
+
* and wait for the push, before the redemption seats the invitation's row and writes the
|
|
1921
|
+
* device's rows (the handler's `catchUpDevice`; `CadreInviteHandler.catchUpDeviceIfLive`
|
|
1922
|
+
* says why those writes need it).
|
|
1923
|
+
* The device is admitted at the backfill's gate for the length of the push only.
|
|
1924
|
+
*
|
|
1925
|
+
* Through {@link PeerJoinBackfill.forceCatchUpPeer}, not `catchUpPeer`: the device's own
|
|
1926
|
+
* `peer:identify` scheduled a run a moment earlier, which was denied if it passed the gate
|
|
1927
|
+
* before the device was added here, and `catchUpPeer` would answer that run's in-flight
|
|
1928
|
+
* status with an empty result.
|
|
1929
|
+
*/
|
|
1930
|
+
private async catchUpRedeemingDevice(peerId: string): Promise<void> {
|
|
1931
|
+
const backfill = this.controlBackfill;
|
|
1932
|
+
if (!backfill) {
|
|
1933
|
+
log('No control backfill on this node; device %s is not caught up before its admission', peerId);
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
this.admittingDevices.set(peerId, (this.admittingDevices.get(peerId) ?? 0) + 1);
|
|
1937
|
+
try {
|
|
1938
|
+
const result = await backfill.forceCatchUpPeer(peerIdFromString(peerId));
|
|
1939
|
+
log('Device %s caught up before its admission: offered=%d accepted=%d rejected=%d',
|
|
1940
|
+
peerId, result.offered, result.accepted, result.rejected.length);
|
|
1941
|
+
} finally {
|
|
1942
|
+
const remaining = (this.admittingDevices.get(peerId) ?? 1) - 1;
|
|
1943
|
+
if (remaining > 0) {
|
|
1944
|
+
this.admittingDevices.set(peerId, remaining);
|
|
1945
|
+
} else {
|
|
1946
|
+
this.admittingDevices.delete(peerId);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1756
1951
|
/**
|
|
1757
1952
|
* The control database's cache-wrapped raw storage, resolved from
|
|
1758
1953
|
* `config.storage.provider` on first use and then held in {@link controlStorage}
|
|
@@ -1844,7 +2039,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1844
2039
|
* only caller in production is `createControlNode`.
|
|
1845
2040
|
*/
|
|
1846
2041
|
private buildControlNodeOptions(): Parameters<typeof createLibp2pNode>[0] {
|
|
1847
|
-
const {
|
|
2042
|
+
const { network, profile } = this.config;
|
|
1848
2043
|
const identityKey = this.identityKey;
|
|
1849
2044
|
// `network.relayAddrs` contributes the bare `/p2p-circuit` SEARCH entry, which
|
|
1850
2045
|
// opens no connection — the reservation is driven explicitly at the END of
|
|
@@ -1863,7 +2058,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1863
2058
|
|
|
1864
2059
|
const nodeOptions: Parameters<typeof createLibp2pNode>[0] = {
|
|
1865
2060
|
port: 0,
|
|
1866
|
-
|
|
2061
|
+
// Empty on purpose: Optimystic refuses to treat a block as never-created until it has
|
|
2062
|
+
// heard from every `bootstrapNodes` peer, and the bring-up quiet period refuses those
|
|
2063
|
+
// dials, so a non-empty list fails start() with `cohort-unreachable`. The configured
|
|
2064
|
+
// peers are dialed once bring-up ends ({@link dialControlBootstrapPeers}).
|
|
2065
|
+
bootstrapNodes: [],
|
|
1867
2066
|
networkName: this.controlNetworkName(),
|
|
1868
2067
|
storage: controlStorageProvider,
|
|
1869
2068
|
fretProfile: profile === 'storage' ? 'core' : 'edge',
|
|
@@ -1927,9 +2126,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1927
2126
|
// `announceAddrs` replaces the advertised set (see warnIfAnnounceAddrsDiscardRelay).
|
|
1928
2127
|
...resolveAnnounceAddrs(network),
|
|
1929
2128
|
// The CONTROL node composes the membership admission gate onto any
|
|
1930
|
-
// caller-supplied gater (deny from
|
|
1931
|
-
//
|
|
1932
|
-
//
|
|
2129
|
+
// caller-supplied gater (a deny from the caller's wins on every composed
|
|
2130
|
+
// hook; the membership policy refuses reservations, never connections,
|
|
2131
|
+
// and puts an unplaced peer's connection on the provisional deadline,
|
|
2132
|
+
// derived from THIS node's declared link because this node decides).
|
|
2133
|
+
// Strand cohort nodes keep the raw configured gater (see
|
|
1933
2134
|
// strand-instance-manager.ts) — their peers are legitimately
|
|
1934
2135
|
// cross-party, so cadre membership must not gate them.
|
|
1935
2136
|
connectionGater: createMembershipConnectionGater(
|
|
@@ -1940,12 +2141,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1940
2141
|
},
|
|
1941
2142
|
network?.connectionGater,
|
|
1942
2143
|
ADMISSION_DECISION_TIMEOUT_MS,
|
|
1943
|
-
|
|
2144
|
+
relayedRequestBudgetMs(network?.linkRoundTripMs)
|
|
1944
2145
|
),
|
|
1945
2146
|
// Fail-closed per-stream authorization for the four Optimystic control-DB
|
|
1946
2147
|
// protocols — the members-only layer the connection gater's stranger
|
|
1947
2148
|
// carve-outs cannot express (see authorizeInboundControlStream). Control
|
|
1948
|
-
// node only: strand cohort nodes serve cross-party peers.
|
|
2149
|
+
// node only: strand cohort nodes serve cross-party peers. The protocol guard
|
|
2150
|
+
// (installControlProtocolGuard) checks these four with this same predicate
|
|
2151
|
+
// first; Optimystic's own check is kept because it is a working, supported
|
|
2152
|
+
// upstream defence, not a second policy.
|
|
1949
2153
|
authorizeInboundStream: (remotePeerId, protocol) =>
|
|
1950
2154
|
this.authorizeInboundControlStream(remotePeerId, protocol)
|
|
1951
2155
|
};
|
|
@@ -1969,86 +2173,85 @@ export class CadreNode implements SAppIdLookup {
|
|
|
1969
2173
|
}
|
|
1970
2174
|
|
|
1971
2175
|
/**
|
|
1972
|
-
* Decide
|
|
1973
|
-
*
|
|
1974
|
-
* {@link createControlNode}.
|
|
1975
|
-
*
|
|
1976
|
-
* and defers to the fail-closed per-stream gates (see
|
|
2176
|
+
* Decide how an inbound CONTROL-network connection from `remotePeerId` is
|
|
2177
|
+
* admitted — the policy behind the connection gater wired in
|
|
2178
|
+
* {@link createControlNode}. Every connection is admitted; the answer decides
|
|
2179
|
+
* only whether it carries the provisional deadline (see
|
|
1977
2180
|
* `membership-connection-gater.ts` for the layer's rationale and the
|
|
1978
|
-
* stranger-open protocol allowlist).
|
|
2181
|
+
* stranger-open protocol allowlist). Asked once when the connection arrives
|
|
2182
|
+
* and again at a provisional connection's deadline.
|
|
1979
2183
|
*
|
|
1980
|
-
* Returns `'admit'` when ANY of:
|
|
2184
|
+
* Returns `'admit'` (no deadline) when ANY of:
|
|
1981
2185
|
* 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
|
|
1982
2186
|
* — not running / DB torn down, absent-or-empty trusted-owner anchor, or
|
|
1983
2187
|
* configured bootstrap/relay infrastructure);
|
|
1984
|
-
* 2.
|
|
1985
|
-
* {@link createInvite}) — the invitee dials in before it is authorized;
|
|
1986
|
-
* 3. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
|
|
2188
|
+
* 2. the peer holds a live DELEGATE ADMISSION GRANT — an authorized member
|
|
1987
2189
|
* announced it (over the strand-addr RPC) as the transport peerId of its
|
|
1988
2190
|
* own strand node, so a NAT'd member's strand node can hold a
|
|
1989
2191
|
* circuit-relay reservation here (see `delegate-admission.ts`).
|
|
1990
2192
|
* Connection only: the per-stream gate below never honors a grant;
|
|
1991
|
-
*
|
|
2193
|
+
* 3. the authorized-member set is empty — cold start: the rows that would
|
|
1992
2194
|
* authorize anyone arrive by replication over these very connections;
|
|
1993
|
-
*
|
|
1994
|
-
*
|
|
2195
|
+
* 4. the peer IS an authorized member; or
|
|
2196
|
+
* 5. an open invitation is OUTSTANDING — at least one unexpired,
|
|
1995
2197
|
* not-fully-consumed invitation this node minted or persisted (see
|
|
1996
2198
|
* `StrandSolicitationService.hasOutstandingInvitation`). A formation
|
|
1997
2199
|
* initiator is another party's peer by design and its token is only
|
|
1998
2200
|
* checkable inside the protocol, so the gate asks the coarser question
|
|
1999
2201
|
* "does this node expect a stranger at all?". REGISTERING the responder
|
|
2000
|
-
* does not
|
|
2001
|
-
*
|
|
2202
|
+
* does not count: every node registers one at {@link start}, and
|
|
2203
|
+
* registering mints no invitation; or
|
|
2204
|
+
* 6. a LIVE cadre invitation exists — a `CadreInvite` row this node holds
|
|
2205
|
+
* that is not withdrawn, unexpired, has uses left and whose issuer is
|
|
2206
|
+
* still an owner (`ControlDatabase.hasLiveCadreInvite`). The device that
|
|
2207
|
+
* redeems it is a stranger until the redemption writes its row, and its
|
|
2208
|
+
* proof of possession is only checkable inside `/sereus/cadre-invite/1.0.0`;
|
|
2209
|
+
* same reasoning as check 5, and the same expectation-of-a-stranger key.
|
|
2002
2210
|
*
|
|
2003
2211
|
* Ordering is semantically free (the checks are OR'd) but decides who pays:
|
|
2004
|
-
* checks 1-
|
|
2005
|
-
*
|
|
2212
|
+
* checks 1-2 are in-memory, 3/4 share one control-DB read, and only a peer
|
|
2213
|
+
* that is otherwise headed for a provisional admission reaches check 5's and
|
|
2214
|
+
* 6's invitation lookups.
|
|
2006
2215
|
*
|
|
2007
|
-
* Caveats of check
|
|
2216
|
+
* Caveats of check 5:
|
|
2008
2217
|
* - the in-memory mint registry dies with the process, so after a restart
|
|
2009
2218
|
* only invitations persisted as `FormationInvite` rows still hold the
|
|
2010
|
-
*
|
|
2219
|
+
* window open (re-mint otherwise);
|
|
2011
2220
|
* - a peer holding a token whose `FormationInvite` row has not replicated to
|
|
2012
|
-
* this node yet is
|
|
2013
|
-
*
|
|
2014
|
-
*
|
|
2015
|
-
* When every check falls through
|
|
2016
|
-
*
|
|
2017
|
-
*
|
|
2018
|
-
*
|
|
2019
|
-
*
|
|
2020
|
-
*
|
|
2021
|
-
*
|
|
2022
|
-
*
|
|
2023
|
-
*
|
|
2024
|
-
* {@link admitControlRelayReservation}
|
|
2025
|
-
*
|
|
2026
|
-
*
|
|
2027
|
-
*
|
|
2028
|
-
* NOTE: check 4/5 runs a control-DB read per inbound connection
|
|
2221
|
+
* this node yet is admitted only provisionally, so its connection is closed
|
|
2222
|
+
* at the deadline unless the row has landed by then.
|
|
2223
|
+
*
|
|
2224
|
+
* When every check falls through: `'admit-provisionally'`, on every node,
|
|
2225
|
+
* relay server or not. The gate sees only a peer id, so it cannot tell an
|
|
2226
|
+
* outsider from a device whose invitation row this node has not received yet
|
|
2227
|
+
* (it redeems here by seating the row from its bundle, after which check 6
|
|
2228
|
+
* admits) or from a sibling whose membership row is still replicating (check 4
|
|
2229
|
+
* admits once it lands; if it has not landed by the deadline, the connection
|
|
2230
|
+
* is closed and either side's next reconcile dial re-forms it, since outbound
|
|
2231
|
+
* dials are never gated). The protocol guard refuses all of them every
|
|
2232
|
+
* members-only protocol meanwhile. A circuit-relay reservation admitted by
|
|
2233
|
+
* {@link admitControlRelayReservation} disarms the deadline (see
|
|
2234
|
+
* `membership-connection-gater.ts` → "The relay-reservation seam").
|
|
2235
|
+
*
|
|
2236
|
+
* NOTE: check 3/4 runs a control-DB read per inbound connection
|
|
2029
2237
|
* (`listAuthorizedMembers`); connections are rare and cadres small, and this
|
|
2030
2238
|
* layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
|
|
2031
2239
|
* read is safe here — unlike the per-stream gate, which must consult the
|
|
2032
2240
|
* materialized {@link authorizedControlPeers} snapshot instead.
|
|
2033
|
-
* NOTE:
|
|
2034
|
-
* stranger with no locally minted invitation in play, on
|
|
2035
|
-
* node
|
|
2036
|
-
*
|
|
2037
|
-
* the
|
|
2038
|
-
* NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
|
|
2039
|
-
* replicated here is denied until the row converges (typically via the
|
|
2040
|
-
* owner); either side's next outbound reconcile dial (outbound is never
|
|
2041
|
-
* gated) re-establishes the DATA link — self-healing for data, visible as a
|
|
2042
|
-
* transient deny. That self-healing story never covered a reservation, which
|
|
2043
|
-
* is exactly why the relay-enabled path above exists.
|
|
2241
|
+
* NOTE: checks 5 and 6 add two more control reads (`hasOutstandingFormationInvite`,
|
|
2242
|
+
* `hasLiveCadreInvite`) for a stranger with no locally minted invitation in play, on
|
|
2243
|
+
* every node, and a provisional connection's deadline runs the whole decision once
|
|
2244
|
+
* more. If stranger connections ever arrive fast enough for those reads — on arrival
|
|
2245
|
+
* or at the deadline — to show, cache the answers for a few seconds.
|
|
2044
2246
|
*/
|
|
2045
2247
|
private async admitInboundControlConnection(remotePeerId: string): Promise<InboundConnectionVerdict> {
|
|
2248
|
+
// NOTE: a node waiting to be claimed (isAwaitingClaim) lands on this empty-anchor
|
|
2249
|
+
// admission too, so a stranger can hold an idle, mute connection to it for as long
|
|
2250
|
+
// as it waits on a public address; if that ever shows up as a resource problem, put
|
|
2251
|
+
// unclaimed-node admissions on the provisional deadline too.
|
|
2046
2252
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
2047
2253
|
return 'admit';
|
|
2048
2254
|
}
|
|
2049
|
-
if (Date.now() < this.enrollmentWindowUntil) {
|
|
2050
|
-
return 'admit';
|
|
2051
|
-
}
|
|
2052
2255
|
if (this.delegateAdmission.has(remotePeerId)) {
|
|
2053
2256
|
return 'admit';
|
|
2054
2257
|
}
|
|
@@ -2063,16 +2266,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2063
2266
|
if (await this.strandSolicitationService?.hasOutstandingInvitation()) {
|
|
2064
2267
|
return 'admit';
|
|
2065
2268
|
}
|
|
2269
|
+
if (await this.controlDatabase?.hasLiveCadreInvite()) {
|
|
2270
|
+
return 'admit';
|
|
2271
|
+
}
|
|
2066
2272
|
} catch (error) {
|
|
2067
2273
|
log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
|
|
2068
2274
|
return 'admit';
|
|
2069
2275
|
}
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
return 'admit-for-relay';
|
|
2073
|
-
}
|
|
2074
|
-
log('admitInboundControlConnection: DENYING inbound from %s — not an authorized member and no enrollment path open', remotePeerId);
|
|
2075
|
-
return 'deny';
|
|
2276
|
+
log('admitInboundControlConnection: %s is not an authorized member and no stranger window is open — admitting provisionally', remotePeerId);
|
|
2277
|
+
return 'admit-provisionally';
|
|
2076
2278
|
}
|
|
2077
2279
|
|
|
2078
2280
|
/**
|
|
@@ -2082,7 +2284,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2082
2284
|
* "The relay-reservation seam"); the circuit-relay server consults it per
|
|
2083
2285
|
* RESERVE request, so it is never called on a node whose relay server is off.
|
|
2084
2286
|
*
|
|
2085
|
-
*
|
|
2287
|
+
* Refuses everyone, ahead of every admission below, while this node is waiting to be
|
|
2288
|
+
* claimed ({@link isAwaitingClaim}): a storage-profile node runs the relay server by
|
|
2289
|
+
* default, and an unclaimed node on a public address must not become anyone's relay.
|
|
2290
|
+
* The empty-anchor admission in check 1 exists for a node that will be enrolled by
|
|
2291
|
+
* its siblings, which an unclaimed node has none of.
|
|
2292
|
+
*
|
|
2293
|
+
* Otherwise admits when ANY of:
|
|
2086
2294
|
* 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
|
|
2087
2295
|
* — not running / DB torn down, absent-or-empty trusted-owner anchor, or
|
|
2088
2296
|
* configured bootstrap/relay infrastructure);
|
|
@@ -2102,14 +2310,18 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2102
2310
|
* that boots, reserves, and only then has its row replicate here would
|
|
2103
2311
|
* otherwise keep that slot spent for the rest of the entry's TTL.
|
|
2104
2312
|
*
|
|
2105
|
-
* The connection gate's stranger carve-outs (an
|
|
2106
|
-
*
|
|
2107
|
-
* exist so a stranger's
|
|
2108
|
-
* neither needs relay capacity. A genuine invitee that does need a relay
|
|
2109
|
-
* takes one from the budget like any other unplaced peer, so
|
|
2110
|
-
* never becomes an unbounded grant of this node's forwarding capacity.
|
|
2313
|
+
* The connection gate's stranger carve-outs (an outstanding formation
|
|
2314
|
+
* invitation, a live cadre invitation) deliberately do NOT extend here: they
|
|
2315
|
+
* exist so a stranger's FORMATION or CADRE-INVITE stream can ride a connection,
|
|
2316
|
+
* and neither needs relay capacity. A genuine invitee that does need a relay
|
|
2317
|
+
* slot takes one from the budget like any other unplaced peer, so a live
|
|
2318
|
+
* invitation never becomes an unbounded grant of this node's forwarding capacity.
|
|
2111
2319
|
*/
|
|
2112
2320
|
private async admitControlRelayReservation(remotePeerId: string): Promise<boolean> {
|
|
2321
|
+
if (this.isAwaitingClaim()) {
|
|
2322
|
+
log('admitControlRelayReservation: REFUSING %s — this node is unclaimed and relays for nobody until it is', remotePeerId);
|
|
2323
|
+
return false;
|
|
2324
|
+
}
|
|
2113
2325
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
2114
2326
|
return this.admitReservationUncounted(remotePeerId);
|
|
2115
2327
|
}
|
|
@@ -2132,25 +2344,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2132
2344
|
return true;
|
|
2133
2345
|
}
|
|
2134
2346
|
|
|
2135
|
-
/**
|
|
2136
|
-
* Hold the control-network inbound gate open for not-yet-authorized peers
|
|
2137
|
-
* until `untilEpochMs` (extends, never shrinks, an already-open window).
|
|
2138
|
-
* {@link createInvite} calls this automatically; a host running an
|
|
2139
|
-
* out-of-band enrollment flow (e.g. accepting a phone whose invite this node
|
|
2140
|
-
* never minted) can open it explicitly before the stranger dials in.
|
|
2141
|
-
*/
|
|
2142
|
-
openEnrollmentWindow(untilEpochMs: number): void {
|
|
2143
|
-
this.enrollmentWindowUntil = Math.max(this.enrollmentWindowUntil, untilEpochMs);
|
|
2144
|
-
log('Enrollment window open until %d', this.enrollmentWindowUntil);
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
2347
|
/**
|
|
2148
2348
|
* Record (or refresh) a delegate admission grant: `delegatePeerId` is the
|
|
2149
2349
|
* transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
|
|
2150
2350
|
* at the CONNECTION and RESERVATION levels (all a circuit-relay reservation
|
|
2151
2351
|
* needs, without spending the unauthorized-reservation budget) for
|
|
2152
|
-
* `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder
|
|
2153
|
-
* authorized
|
|
2352
|
+
* `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder, which the
|
|
2353
|
+
* protocol guard admits only authorized members to; public so tests can drive the
|
|
2154
2354
|
* admission policy without a full strand launch. A re-announce for the same
|
|
2155
2355
|
* (announcer, strand) REPLACES the previous delegate rather than
|
|
2156
2356
|
* accumulating. Never honored by {@link authorizeInboundControlStream}.
|
|
@@ -2192,25 +2392,35 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2192
2392
|
}
|
|
2193
2393
|
|
|
2194
2394
|
/**
|
|
2195
|
-
* Per-stream authorization for the
|
|
2196
|
-
* (
|
|
2197
|
-
*
|
|
2198
|
-
*
|
|
2199
|
-
*
|
|
2395
|
+
* Per-stream authorization for the protocol guard's `members-snapshot` class
|
|
2396
|
+
* (`control-protocol-guard.ts`): the four Optimystic control-DB protocols
|
|
2397
|
+
* (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), FRET's
|
|
2398
|
+
* five, and any protocol nobody classed. Also wired as Optimystic's
|
|
2399
|
+
* `authorizeInboundStream` in {@link createControlNode} for the four DB
|
|
2400
|
+
* protocols. The fail-closed layer behind the fail-open connection gater.
|
|
2401
|
+
* Control node ONLY: strand cohort nodes legitimately serve cross-party peers.
|
|
2200
2402
|
*
|
|
2201
2403
|
* The STRICT SUBSET of {@link admitInboundControlConnection}: the same
|
|
2202
2404
|
* "no basis to judge" admissions (shared via
|
|
2203
2405
|
* {@link admitControlPeerUnconditionally}), minus the stranger carve-outs
|
|
2204
|
-
* (
|
|
2205
|
-
* grant). The first two exist so a stranger can reach `/sereus/
|
|
2206
|
-
* and `/sereus/
|
|
2207
|
-
* stranger to `repo`
|
|
2406
|
+
* (outstanding open invitation, live cadre invitation, delegate admission
|
|
2407
|
+
* grant). The first two exist so a stranger can reach `/sereus/formation/1.0.0`
|
|
2408
|
+
* and `/sereus/cadre-invite/1.0.0` — neither is gated here, and admitting a
|
|
2409
|
+
* stranger to `repo` while an invitation is live is exactly the hole this
|
|
2208
2410
|
* gate closes. A DELEGATE-admitted connection (a member's strand node
|
|
2209
2411
|
* holding a circuit-relay reservation, see `delegate-admission.ts`) is
|
|
2210
2412
|
* likewise exactly a case this gate must still refuse: the delegate gets
|
|
2211
2413
|
* the connection and never the control DB.
|
|
2212
2414
|
*
|
|
2213
|
-
*
|
|
2415
|
+
* Refuses everyone, ahead of every admission below, while this node is waiting to be
|
|
2416
|
+
* claimed ({@link isAwaitingClaim}). The empty-anchor and empty-snapshot admissions
|
|
2417
|
+
* exist so a brand-new node can take replication from the siblings that will
|
|
2418
|
+
* authorize it; an unclaimed node has no siblings yet, and the rows that will
|
|
2419
|
+
* authorize anyone arrive only after the claim, over the owner's streams that the
|
|
2420
|
+
* same two admissions then let in. Configured bootstrap infra is refused too: a
|
|
2421
|
+
* relay opens no control-database stream.
|
|
2422
|
+
*
|
|
2423
|
+
* Otherwise admits when ANY of:
|
|
2214
2424
|
* 1. a shared-baseline check admits (not running / no DB, empty anchor,
|
|
2215
2425
|
* configured bootstrap infra);
|
|
2216
2426
|
* 2. the materialized authorized set is empty — cold start, before the
|
|
@@ -2233,6 +2443,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2233
2443
|
* ever becomes a supported deployment, this gate needs a key-based admission.
|
|
2234
2444
|
*/
|
|
2235
2445
|
private authorizeInboundControlStream(remotePeerId: string, protocol: string): boolean {
|
|
2446
|
+
if (this.isAwaitingClaim()) {
|
|
2447
|
+
log('authorizeInboundControlStream: DENYING %s on %s — this node is unclaimed and replicates with nobody until it is',
|
|
2448
|
+
remotePeerId, protocol);
|
|
2449
|
+
return false;
|
|
2450
|
+
}
|
|
2236
2451
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
2237
2452
|
return true;
|
|
2238
2453
|
}
|
|
@@ -2262,13 +2477,19 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2262
2477
|
* read here would sleep its backoff holding the lock and stall every other local
|
|
2263
2478
|
* writer. Nothing is lost — this refresh already keeps the previous snapshot on
|
|
2264
2479
|
* failure and is re-driven by the next membership write and by the timed reconcile.
|
|
2480
|
+
*
|
|
2481
|
+
* The trusted-owner anchor sync ({@link syncOwnerAnchor}) runs FIRST, so the snapshot
|
|
2482
|
+
* built here judges `CadrePeer` rows against the anchor as the `OwnerKey` table now
|
|
2483
|
+
* has it. The invitation chain both reads may need is loaded at most once per refresh.
|
|
2265
2484
|
*/
|
|
2266
2485
|
private async refreshAuthorizedControlPeers(reason: string): Promise<void> {
|
|
2267
2486
|
if (!this._running || !this.controlDatabase) {
|
|
2268
2487
|
return;
|
|
2269
2488
|
}
|
|
2270
2489
|
try {
|
|
2271
|
-
const
|
|
2490
|
+
const loadChain = this.invitationChainSource(this.controlDatabase, false);
|
|
2491
|
+
await this.syncOwnerAnchor(reason, false, loadChain);
|
|
2492
|
+
const members = await this.collectAuthorizedMembers(this.controlDatabase, false, loadChain);
|
|
2272
2493
|
this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
|
|
2273
2494
|
log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
|
|
2274
2495
|
// Remember the party's size for the NEXT launch's control-node repair yardstick
|
|
@@ -2340,6 +2561,31 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2340
2561
|
}
|
|
2341
2562
|
}
|
|
2342
2563
|
|
|
2564
|
+
/**
|
|
2565
|
+
* Add the configured `controlNetwork.bootstrapNodes` to the control node's address book
|
|
2566
|
+
* and dial each peer once — what `@libp2p/bootstrap` would have done had the node been
|
|
2567
|
+
* built with them. Best-effort: a failed dial is logged, and the connection manager and
|
|
2568
|
+
* FRET reconnect from the address book as they do for any known peer.
|
|
2569
|
+
*/
|
|
2570
|
+
private async dialControlBootstrapPeers(): Promise<void> {
|
|
2571
|
+
const node = this.controlNode;
|
|
2572
|
+
if (!node) {
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
const groups = groupAddrsByPeerId(this.config.controlNetwork.bootstrapNodes);
|
|
2576
|
+
await Promise.all([...groups].map(async ([peerId, addrs]) => {
|
|
2577
|
+
if (peerId === node.peerId.toString()) {
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
await mergePeerAddrs(node, peerId, addrs);
|
|
2581
|
+
try {
|
|
2582
|
+
await node.dial(peerIdFromString(peerId));
|
|
2583
|
+
} catch (error) {
|
|
2584
|
+
log('dialControlBootstrapPeers: dial to bootstrap peer %s failed: %o', peerId, error);
|
|
2585
|
+
}
|
|
2586
|
+
}));
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2343
2589
|
/**
|
|
2344
2590
|
* PeerIds of the configured control bootstrap nodes (relay/bootstrap
|
|
2345
2591
|
* infrastructure — always admitted inbound). Parsed once, lazily; an
|
|
@@ -2468,6 +2714,11 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2468
2714
|
await this.registerSelf();
|
|
2469
2715
|
} catch (error) {
|
|
2470
2716
|
// Background task — a failed publish must not crash the node.
|
|
2717
|
+
// NOTE: a restarted machine whose own CadrePeer row replicates in while this update
|
|
2718
|
+
// runs fails here with ConcurrentModificationError (seen on the owner's restart in
|
|
2719
|
+
// every traced run of cadre-invite-any-member), leaving its record unrefreshed until
|
|
2720
|
+
// the next heartbeat. If a restarted machine's changed addresses must publish sooner,
|
|
2721
|
+
// retry once here.
|
|
2471
2722
|
log('Self-registration failed: %o', error);
|
|
2472
2723
|
}
|
|
2473
2724
|
// Wire the ongoing refresh + control-cohort reconcile cadence, then run
|
|
@@ -2541,7 +2792,9 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2541
2792
|
const existing = await this.controlDatabase.queryPeerRecord(peerId);
|
|
2542
2793
|
|
|
2543
2794
|
if (!existing) {
|
|
2544
|
-
|
|
2795
|
+
// A listener-only service (`enableSeedListener`, a claim node's) holds no owner key and
|
|
2796
|
+
// cannot sign the insert, so it is "no owner service" here too.
|
|
2797
|
+
if (!this.seedBootstrapService?.canAuthorize()) {
|
|
2545
2798
|
// "No row" also covers "revoked": queryPeerRecord reads a row whose StampId is
|
|
2546
2799
|
// retired in Revocation as absent, so a removed node lands here every heartbeat
|
|
2547
2800
|
// and stops refreshing its own record — correct, but say so, or the message reads
|
|
@@ -2689,6 +2942,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2689
2942
|
): PeerAddressRecord {
|
|
2690
2943
|
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
2691
2944
|
return signPeerRecord(
|
|
2945
|
+
this.partyId,
|
|
2692
2946
|
{ peerId, publicKey: signingKey.publicKeyB64, addrs, updatedAt },
|
|
2693
2947
|
signingKey.privateKeyB64
|
|
2694
2948
|
);
|
|
@@ -2771,6 +3025,28 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2771
3025
|
return ed25519KeyPairFromLibp2p(this.identityKey);
|
|
2772
3026
|
}
|
|
2773
3027
|
|
|
3028
|
+
/**
|
|
3029
|
+
* Publish this node's own address record from the reconcile pass, for a CLAIMED node that
|
|
3030
|
+
* has not published one this session. Its owner writes its `CadrePeer` row only after the
|
|
3031
|
+
* claim is accepted (`claimNode`), so the row reaches this node by replication, after the
|
|
3032
|
+
* boot-time publish and the first-growth drain have both found nothing to publish; without
|
|
3033
|
+
* this the record would wait for the heartbeat ({@link startRecordRefresh}), and the cadre
|
|
3034
|
+
* could not resolve the node's addresses until then. The reconcile pass is where replicated
|
|
3035
|
+
* membership is picked up, so each pass tries until the first publish lands. A try before
|
|
3036
|
+
* the row has arrived is a `skipped` publish: one `CadrePeer` read. A claimed node later
|
|
3037
|
+
* removed from its cadre never publishes again, so it pays that read on every pass.
|
|
3038
|
+
*/
|
|
3039
|
+
private async publishSelfRecordOnceClaimed(): Promise<void> {
|
|
3040
|
+
if (!this.config.claim || this.isAwaitingClaim() || this.lastSelfRecordPublishAt !== null) {
|
|
3041
|
+
return;
|
|
3042
|
+
}
|
|
3043
|
+
const outcome = await this.registerSelf().catch((error: unknown) => {
|
|
3044
|
+
log('reconcileControlCohort: claimed-node self-record publish failed (continuing): %o', error);
|
|
3045
|
+
return 'skipped' as const;
|
|
3046
|
+
});
|
|
3047
|
+
log('reconcileControlCohort: claimed node self-record publish: %s', outcome);
|
|
3048
|
+
}
|
|
3049
|
+
|
|
2774
3050
|
/**
|
|
2775
3051
|
* Collect this node's current dialable addresses for publication, signaling
|
|
2776
3052
|
* (`/p2p-circuit`) first. Prefers the best invite/NAT-resolved set and folds
|
|
@@ -2828,12 +3104,14 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2828
3104
|
// `/p2p-circuit` address the boot-time publish could not carry). Harmless
|
|
2829
3105
|
// where a late-authorized node's addresses keep churning (NAT/relay rotation
|
|
2830
3106
|
// fire the event anyway), and invisible while parties authorize members before
|
|
2831
|
-
// they boot.
|
|
2832
|
-
//
|
|
2833
|
-
//
|
|
2834
|
-
// list of triggers
|
|
2835
|
-
//
|
|
2836
|
-
//
|
|
3107
|
+
// they boot. A claimed node is always authorized after it boots, and the reconcile
|
|
3108
|
+
// pass publishes for it ({@link publishSelfRecordOnceClaimed}). If late enrollment
|
|
3109
|
+
// becomes the normal path for other nodes too — an invited phone is exactly that —
|
|
3110
|
+
// drop that method's claim condition rather than lengthening this list of triggers:
|
|
3111
|
+
// the membership-change seam (`ControlDatabase.setMembershipChangeListener`) fires
|
|
3112
|
+
// on LOCAL commits only, and the row a late-authorized node waits for is written
|
|
3113
|
+
// elsewhere. Measured while writing `relay-only-control-addr.integration.ts` case 4,
|
|
3114
|
+
// which drives `registerSelf()` explicitly because neither trigger is dependable there.
|
|
2837
3115
|
this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
|
|
2838
3116
|
(this.recordRefreshTimer as { unref?: () => void } | null)?.unref?.();
|
|
2839
3117
|
|
|
@@ -2945,7 +3223,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
2945
3223
|
}
|
|
2946
3224
|
|
|
2947
3225
|
// Self-signature over (peerId, addrs, updatedAt).
|
|
2948
|
-
if (!verifyPeerRecordSignature(record)) {
|
|
3226
|
+
if (!verifyPeerRecordSignature(this.partyId, record)) {
|
|
2949
3227
|
// Field detail distinguishes "holding the owner-vouch revision (Sig null,
|
|
2950
3228
|
// no addrs — a not-yet-self-published row)" from a genuinely corrupt or
|
|
2951
3229
|
// mixed-revision row. Sig/addrs are public replicated data.
|
|
@@ -3138,6 +3416,10 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3138
3416
|
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
3139
3417
|
return { dialed: [] };
|
|
3140
3418
|
}
|
|
3419
|
+
await this.publishSelfRecordOnceClaimed();
|
|
3420
|
+
if (!this._running || !this.controlNode || !this.controlDatabase) {
|
|
3421
|
+
return { dialed: [] };
|
|
3422
|
+
}
|
|
3141
3423
|
// Refresh relay delegate grants BEFORE the sibling enumeration below: a
|
|
3142
3424
|
// solo cadre with a party relay has no siblings to dial but must still keep
|
|
3143
3425
|
// its running strands' grants alive (a dropped relay connection re-dials
|
|
@@ -3433,8 +3715,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3433
3715
|
* addresses ({@link dialPeerAddrs}): the reconcile pass's steady-state sibling
|
|
3434
3716
|
* ({@link dialControlSibling}) and cold-start bootstrap peer
|
|
3435
3717
|
* ({@link dialBootstrapPeer}) dials, and — handed to every
|
|
3436
|
-
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials
|
|
3437
|
-
* `dialInvite`.
|
|
3718
|
+
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials.
|
|
3438
3719
|
*
|
|
3439
3720
|
* See `peer-dial.ts`'s `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` for why a peer's dial is
|
|
3440
3721
|
* bounded as a whole, and `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS` for why each
|
|
@@ -3870,7 +4151,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3870
4151
|
return;
|
|
3871
4152
|
}
|
|
3872
4153
|
this.pendingRevocations.set(revocation.stampId, revocation);
|
|
3873
|
-
log('%s
|
|
4154
|
+
log('%s tombstone for %s committed while alone (0 control connections); Revocation tombstone ' +
|
|
3874
4155
|
'(stamp %s) queued for re-issue on cohort growth', revocation.tableName, revocation.rowKey, revocation.stampId);
|
|
3875
4156
|
}
|
|
3876
4157
|
|
|
@@ -3945,7 +4226,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
3945
4226
|
// 4. In-session pending owner authorize writes.
|
|
3946
4227
|
await this.drainPendingPeerWrites();
|
|
3947
4228
|
|
|
3948
|
-
// 5.
|
|
4229
|
+
// 5. Pending joins this process wrote while alone. Their tombstones went out in step 1.
|
|
3949
4230
|
await this.pendingJoinRunner?.reissueWritesMadeAlone();
|
|
3950
4231
|
}
|
|
3951
4232
|
|
|
@@ -4031,6 +4312,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4031
4312
|
this.pendingRevocations.delete(row.stampId);
|
|
4032
4313
|
}
|
|
4033
4314
|
} catch (error) {
|
|
4315
|
+
// NOTE: a device whose owner key has been removed keeps running this drain (and the
|
|
4316
|
+
// membership re-touches of reissuePendingPeerWrites / reissueAuthoredMembershipRows)
|
|
4317
|
+
// on every growth edge, and once its own tombstone has reached it every attempt
|
|
4318
|
+
// fails the schema's live-owner clause and lands here. Noise, not harm; a node that
|
|
4319
|
+
// learns it has been revoked shutting itself down belongs to the device-removal plan
|
|
4320
|
+
// ticket (remove-a-device-or-app-from-a-cadre).
|
|
4034
4321
|
log('drain: revocation re-issue failed; leaving queued for next growth: %o', error);
|
|
4035
4322
|
}
|
|
4036
4323
|
}
|
|
@@ -4268,6 +4555,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4268
4555
|
// Strictly increase UpdatedAt even on a same-millisecond re-publish (rotation).
|
|
4269
4556
|
const updatedAt = Math.max(Date.now(), (existing?.updatedAt ?? 0) + 1);
|
|
4270
4557
|
const record = signDeviceTokenRecord(
|
|
4558
|
+
this.partyId,
|
|
4271
4559
|
{ peerId, platform, token, updatedAt },
|
|
4272
4560
|
signingKey.privateKeyB64
|
|
4273
4561
|
);
|
|
@@ -4364,7 +4652,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4364
4652
|
|
|
4365
4653
|
// Self-signature over (peerId, platform, token, updatedAt), verified against the
|
|
4366
4654
|
// CadrePeer.PublicKey bound to this peerId.
|
|
4367
|
-
if (!verifyDeviceTokenSignature(record, peerRecord.publicKey)) {
|
|
4655
|
+
if (!verifyDeviceTokenSignature(this.partyId, record, peerRecord.publicKey)) {
|
|
4368
4656
|
log('resolveDeviceToken: signature verification failed for %s', peerId);
|
|
4369
4657
|
return null;
|
|
4370
4658
|
}
|
|
@@ -4625,6 +4913,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
4625
4913
|
this.strandAddrService = null;
|
|
4626
4914
|
}
|
|
4627
4915
|
|
|
4916
|
+
// Unregister the cadre invitation redemption handler, for the same reason.
|
|
4917
|
+
if (this.cadreInviteHandler && this.controlNode) {
|
|
4918
|
+
await this.cadreInviteHandler.unregister(this.controlNode);
|
|
4919
|
+
}
|
|
4920
|
+
this.cadreInviteHandler = null;
|
|
4921
|
+
|
|
4628
4922
|
// Tear down the push-wake fan-out (releases the notifier's APNs HTTP/2 session).
|
|
4629
4923
|
if (this.pushFanoutService) {
|
|
4630
4924
|
await this.pushFanoutService.close().catch((err) => log('Push fan-out close failed: %o', err));
|
|
@@ -7197,6 +7491,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7197
7491
|
if (!this.controlNode || !this.controlDatabase) {
|
|
7198
7492
|
throw new Error('CadreNode must be started before initializing seed bootstrap');
|
|
7199
7493
|
}
|
|
7494
|
+
// A hosted node waiting to be claimed never holds an owner key: the claim is its
|
|
7495
|
+
// one trust source, and genesis-anchoring a key here would mark it claimed by
|
|
7496
|
+
// itself. Refused here rather than in start() because this call comes after it.
|
|
7497
|
+
if (this.config.claim) {
|
|
7498
|
+
throw new Error('CadreNode: a node started with `claim` is owned by its claimant and cannot initialize seed bootstrap with an owner key');
|
|
7499
|
+
}
|
|
7200
7500
|
|
|
7201
7501
|
// A node wiring seed-bootstrap with an owner PRIVATE key is declaring that
|
|
7202
7502
|
// key an authority of its own party (founder genesis / self-signing owner)
|
|
@@ -7206,13 +7506,15 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7206
7506
|
// NOTE: non-founder members that also wire seed-bootstrap with their own
|
|
7207
7507
|
// derived key (e.g. the phone joiner path in runOwnerGenesis, which needs
|
|
7208
7508
|
// it to self-publish its CadrePeer row) self-anchor a key that is not a
|
|
7209
|
-
// party authority.
|
|
7210
|
-
//
|
|
7211
|
-
//
|
|
7212
|
-
//
|
|
7213
|
-
//
|
|
7214
|
-
//
|
|
7215
|
-
//
|
|
7509
|
+
// party authority. The anchor sync (`syncOwnerAnchor`) does not correct this:
|
|
7510
|
+
// a `genesis` entry is base, kept whether or not a live `OwnerKey` row vouches
|
|
7511
|
+
// it. Harmless while such a node never mints an invite: the store does not
|
|
7512
|
+
// replicate, so a node trusting itself grants nothing to others. But
|
|
7513
|
+
// `createCadreInvitation` hands out the anchor's contents as the device's pins,
|
|
7514
|
+
// so the moment a non-founder member mints an invitation it exports its own
|
|
7515
|
+
// non-authority key as a cadre owner key. If that becomes reachable (today only
|
|
7516
|
+
// cadre-cli/cadre-host owners mint invitations), gate this self-anchor on the
|
|
7517
|
+
// actual OwnerKey genesis insert instead.
|
|
7216
7518
|
if (this.trustedOwnerStore) {
|
|
7217
7519
|
void this.trustedOwnerStore
|
|
7218
7520
|
.trust(ed25519PublicKeyFromPrivate(ownerPrivateKey), 'genesis')
|
|
@@ -7222,9 +7524,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7222
7524
|
await this.installSeedBootstrapService(new SeedBootstrapService({
|
|
7223
7525
|
partyId: this.config.controlNetwork.partyId,
|
|
7224
7526
|
ownerPrivateKey,
|
|
7225
|
-
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
7226
7527
|
...this.seedServiceBudgets(),
|
|
7227
|
-
trustPolicy: this.
|
|
7528
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
7228
7529
|
// Seed trust anchors on the node-local store (seeded just above with this
|
|
7229
7530
|
// node's own genesis key), never on the replicated OwnerKey table.
|
|
7230
7531
|
...(this.trustedOwnerStore ? { trustedOwners: this.trustedOwnerStore } : {}),
|
|
@@ -7342,14 +7643,21 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7342
7643
|
* 1. it is not this node itself — a node publishes its own `CadrePeer` address
|
|
7343
7644
|
* row so its dialable address rides in seeds, but "self" is not a peer this
|
|
7344
7645
|
* node authorized;
|
|
7345
|
-
* 2. its `CadrePeer` row carries a complete
|
|
7346
|
-
* `VouchSig` all non-null)
|
|
7646
|
+
* 2. its `CadrePeer` row carries a complete proof: EITHER an owner voucher
|
|
7647
|
+
* (`StampId`, `VouchOwner`, `VouchSig` all non-null) OR an invitation admission
|
|
7648
|
+
* (`VouchSig` null, `VouchUsage` naming a `CadreInviteUsage` row);
|
|
7347
7649
|
* 3. `VouchOwner` is in the NODE-LOCAL trusted-owner anchor
|
|
7348
|
-
* ({@link getTrustedOwnerStore})
|
|
7349
|
-
*
|
|
7350
|
-
*
|
|
7351
|
-
*
|
|
7352
|
-
*
|
|
7650
|
+
* ({@link getTrustedOwnerStore}), which follows the replicated `OwnerKey` table
|
|
7651
|
+
* only along chains that verify from this node's out-of-band keys
|
|
7652
|
+
* ({@link syncOwnerAnchor}) — never the table itself, which any stranger can
|
|
7653
|
+
* genesis-pollute; and
|
|
7654
|
+
* 4. the proof verifies: for a voucher, `VouchSig` is that owner's signature over
|
|
7655
|
+
* the row's voucher digest ({@link verifyCadrePeerVoucher}), so the anchored
|
|
7656
|
+
* owner really vouched THIS peer id under THIS row's nonce; for an admission,
|
|
7657
|
+
* the chain row → usage → invitation → anchored issuer verifies link by link
|
|
7658
|
+
* ({@link verifyInvitationAdmission} — the issuer is `VouchOwner`, the invitation's
|
|
7659
|
+
* own owner signature, the holder's and the device's signatures, and the stored
|
|
7660
|
+
* peer key really is the key behind the peer id); and
|
|
7353
7661
|
* 5. the row's `StampId` is NOT retired in `CadreControl.Revocation` — enforced
|
|
7354
7662
|
* upstream in {@link ControlDatabase.queryCadrePeers}, which drops retired rows
|
|
7355
7663
|
* before ANY reader (this predicate included) sees them, so a row resurrected
|
|
@@ -7358,18 +7666,26 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7358
7666
|
* local rows) is still inert to every reader that has the tombstone.
|
|
7359
7667
|
*
|
|
7360
7668
|
* Fail-closed at every step: a missing anchor (pre-start), an empty anchor (a
|
|
7361
|
-
* not-yet-enrolled node authorizes no one), a null/partial
|
|
7362
|
-
* unanchored `VouchOwner`,
|
|
7363
|
-
* having an address row is NOT
|
|
7364
|
-
* strand-address
|
|
7669
|
+
* not-yet-enrolled node authorizes no one), a null/partial proof, an
|
|
7670
|
+
* unanchored `VouchOwner`, a bad signature, or a usage or invitation row this node
|
|
7671
|
+
* does not hold all yield "not authorized" — having an address row is NOT
|
|
7672
|
+
* membership. The protocol guard admits wake and strand-address requests by this set,
|
|
7673
|
+
* NOT the addressable one.
|
|
7674
|
+
*
|
|
7675
|
+
* The usage and invitation tables are read only when at least one row needs them,
|
|
7676
|
+
* so a cadre with no invitation-admitted member stays at one read per call.
|
|
7365
7677
|
*
|
|
7366
7678
|
* NOTE (rotation): if a party owner rotates keys and only the NEW key is pinned
|
|
7367
7679
|
* in the anchor, rows the OLD key vouched fail check 3 until re-vouched — a
|
|
7368
7680
|
* legit member goes un-authorized on readers that only pin the new key. Full
|
|
7369
7681
|
* rotation handling (re-vouch on rotate) is the
|
|
7370
|
-
* `flip-strand-membership-rotation-known-gap` work, not this predicate's.
|
|
7682
|
+
* `flip-strand-membership-rotation-known-gap` work, not this predicate's. The
|
|
7683
|
+
* `OwnerKey` table differs: an owner the rotated-out (or removed) key vouched keeps
|
|
7684
|
+
* its place in every anchor that had derived it ({@link syncOwnerAnchor}: a derived
|
|
7685
|
+
* entry stays while its own row is live), and is only out of reach for a machine that
|
|
7686
|
+
* never held it until a remaining owner re-adds it or an invitation pins it.
|
|
7371
7687
|
*
|
|
7372
|
-
* @param retry - Whether the underlying membership
|
|
7688
|
+
* @param retry - Whether the underlying membership reads may retry a transient cluster
|
|
7373
7689
|
* failure. Only {@link refreshAuthorizedControlPeers} passes `false`, because it runs
|
|
7374
7690
|
* as the control database's membership listener with that database's write lock held;
|
|
7375
7691
|
* see its comment.
|
|
@@ -7378,10 +7694,23 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7378
7694
|
if (!this.controlDatabase) {
|
|
7379
7695
|
throw new Error('CadreNode must be started before listing members');
|
|
7380
7696
|
}
|
|
7697
|
+
return await this.collectAuthorizedMembers(this.controlDatabase, retry, this.invitationChainSource(this.controlDatabase, retry));
|
|
7698
|
+
}
|
|
7699
|
+
|
|
7700
|
+
/**
|
|
7701
|
+
* The body of {@link listAuthorizedMembers}, with the invitation chain supplied as a
|
|
7702
|
+
* source so the membership refresh can share one load with {@link syncOwnerAnchor}.
|
|
7703
|
+
*/
|
|
7704
|
+
private async collectAuthorizedMembers(
|
|
7705
|
+
controlDatabase: ControlDatabase,
|
|
7706
|
+
retry: boolean,
|
|
7707
|
+
loadChain: InvitationChainSource,
|
|
7708
|
+
): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
|
|
7381
7709
|
const selfPeerId = this.peerId?.toString();
|
|
7382
|
-
const rows = await
|
|
7710
|
+
const rows = await controlDatabase.queryCadrePeers(retry);
|
|
7711
|
+
const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
|
|
7383
7712
|
const authorized = rows
|
|
7384
|
-
.filter(row => row.peerId !== selfPeerId && this.
|
|
7713
|
+
.filter(row => row.peerId !== selfPeerId && this.hasAnchoredProof(row, chain))
|
|
7385
7714
|
.map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
|
|
7386
7715
|
// A node whose anchor was never seeded (no invite pin, no operator pin, not a
|
|
7387
7716
|
// founder) refuses every wake and strand-addr request, which from the outside
|
|
@@ -7395,9 +7724,160 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7395
7724
|
|
|
7396
7725
|
/**
|
|
7397
7726
|
* Checks 2–4 of the authorized-membership predicate (see
|
|
7398
|
-
* {@link listAuthorizedMembers}) for one `CadrePeer` row
|
|
7399
|
-
*
|
|
7400
|
-
* (
|
|
7727
|
+
* {@link listAuthorizedMembers}) for one `CadrePeer` row, by the kind of proof it
|
|
7728
|
+
* carries: an owner voucher ({@link hasAnchoredVoucher}) or an invitation admission
|
|
7729
|
+
* ({@link verifyInvitationAdmission} over the usage and invitation rows in `chain`,
|
|
7730
|
+
* loaded only when some row needs them). A row with a `VouchSig` is judged as a
|
|
7731
|
+
* voucher even if it also names a usage — the signature is the stronger proof and the
|
|
7732
|
+
* one an owner re-vouch leaves behind.
|
|
7733
|
+
*/
|
|
7734
|
+
private hasAnchoredProof(row: CadrePeerVoucherFields, chain: InvitationChain | null): boolean {
|
|
7735
|
+
if (!isInvitationAdmitted(row)) {
|
|
7736
|
+
return this.hasAnchoredVoucher(row);
|
|
7737
|
+
}
|
|
7738
|
+
const usage = chain?.usages.get(row.vouchUsage!);
|
|
7739
|
+
const invite = usage === undefined ? undefined : chain?.invites.get(usage.inviteKey);
|
|
7740
|
+
if (usage === undefined || invite === undefined) {
|
|
7741
|
+
return false;
|
|
7742
|
+
}
|
|
7743
|
+
return verifyInvitationAdmission(this.partyId, row, usage, invite, key => this.trustedOwnerStore?.has(key) ?? false);
|
|
7744
|
+
}
|
|
7745
|
+
|
|
7746
|
+
/**
|
|
7747
|
+
* Recompute the node-local trusted-owner anchor from the replicated `OwnerKey` table along
|
|
7748
|
+
* verifiable chains (`owner-anchor-sync.ts` → {@link deriveOwnerAnchor}), as the first step
|
|
7749
|
+
* of every membership refresh ({@link refreshAuthorizedControlPeers}) so the snapshot built
|
|
7750
|
+
* in the same pass judges `CadrePeer` rows against the updated anchor. A key whose live
|
|
7751
|
+
* row's stored proof verifies against a key already anchored enters under `chain` and
|
|
7752
|
+
* stays while its row is live; a key an anchored owner's verifiable tombstone names
|
|
7753
|
+
* leaves, base pins included — which is how a config pin of a removed key, re-anchored at
|
|
7754
|
+
* every start ({@link initializeTrustedOwnerStore}), is removed again. The derivation
|
|
7755
|
+
* refuses removals that would empty the anchor; they are logged here.
|
|
7756
|
+
*
|
|
7757
|
+
* With an EMPTY anchor the sync does nothing: there is no key to derive from, and a node
|
|
7758
|
+
* waiting to be claimed ({@link isAwaitingClaim}: claim secret set, anchor empty) must
|
|
7759
|
+
* stay empty until the claim anchors its claimant — the replicated table must never seat
|
|
7760
|
+
* the first key. The early return below is the seam that rests on.
|
|
7761
|
+
*
|
|
7762
|
+
* Any read failure skips the whole pass and leaves the anchor as it was; the refresh then
|
|
7763
|
+
* builds its snapshot against that anchor, which is also what its previous snapshot was
|
|
7764
|
+
* judged against. Caught here rather than failing the refresh, because the membership read
|
|
7765
|
+
* tolerates an unreachable `Revocation` block on an isolated node
|
|
7766
|
+
* (`ControlDatabase.readRevokedStampRows`) and `queryRevocations` does not: failing the
|
|
7767
|
+
* whole refresh on that would freeze such a node's gate snapshot.
|
|
7768
|
+
* NOTE: on such a node the sync is skipped at every refresh until the block is held; if an
|
|
7769
|
+
* isolated node ever has to derive owner additions while cut off, give `queryRevocations`
|
|
7770
|
+
* the same cohort-unreachable fallback.
|
|
7771
|
+
*
|
|
7772
|
+
* NOTE: cost per membership refresh is one `OwnerKey` read and one `Revocation` read (the
|
|
7773
|
+
* tombstone list, whose `OwnerKey` stamps are handed to `queryOwnerKeyRows` so it does not
|
|
7774
|
+
* read the retired set again), plus the invitation chain when some owner row is
|
|
7775
|
+
* invitation-admitted — shared with the membership read through `loadChain`; the memo
|
|
7776
|
+
* suggestion on {@link loadInvitationChain} covers that part. Before the Revocation ledger
|
|
7777
|
+
* marker is filed that one read is a cohort consult per refresh, which
|
|
7778
|
+
* `control-founding-consult-budget.spec.ts` pins.
|
|
7779
|
+
*
|
|
7780
|
+
* @param retry - forwarded to every read; `false` on the refresh path, for the reason on
|
|
7781
|
+
* {@link refreshAuthorizedControlPeers}.
|
|
7782
|
+
*/
|
|
7783
|
+
private async syncOwnerAnchor(reason: string, retry: boolean, loadChain: InvitationChainSource): Promise<void> {
|
|
7784
|
+
const store = this.trustedOwnerStore;
|
|
7785
|
+
const controlDatabase = this.controlDatabase;
|
|
7786
|
+
if (!store || !controlDatabase) {
|
|
7787
|
+
return;
|
|
7788
|
+
}
|
|
7789
|
+
const before = store.sources();
|
|
7790
|
+
if (before.size === 0) {
|
|
7791
|
+
return;
|
|
7792
|
+
}
|
|
7793
|
+
let target: ReadonlySet<string>;
|
|
7794
|
+
try {
|
|
7795
|
+
const tombstones = await controlDatabase.queryRevocations(retry);
|
|
7796
|
+
const retired = new Set(tombstones.filter(tombstone => tombstone.tableName === 'OwnerKey').map(tombstone => tombstone.stampId));
|
|
7797
|
+
const rows = await controlDatabase.queryOwnerKeyRows(retry, retired);
|
|
7798
|
+
const chain = rows.some(isInvitationAdmitted) ? await loadChain() : null;
|
|
7799
|
+
const derivation = deriveOwnerAnchor({ partyId: this.partyId, anchor: before, rows, tombstones, chain });
|
|
7800
|
+
if (derivation.refusedRemovals.length > 0) {
|
|
7801
|
+
log('syncOwnerAnchor(%s): refused %d owner removal(s) that would empty the anchor: %o', reason, derivation.refusedRemovals.length, derivation.refusedRemovals);
|
|
7802
|
+
}
|
|
7803
|
+
target = derivation.target;
|
|
7804
|
+
} catch (error) {
|
|
7805
|
+
log('syncOwnerAnchor(%s): read failed — anchor left as it was: %o', reason, error);
|
|
7806
|
+
return;
|
|
7807
|
+
}
|
|
7808
|
+
await this.applyOwnerAnchor(store, before, target, reason);
|
|
7809
|
+
}
|
|
7810
|
+
|
|
7811
|
+
/**
|
|
7812
|
+
* Apply one pass's result: `chain` entries for the keys it newly derived, removal of the keys
|
|
7813
|
+
* it started with that are outside `target` — base keys a verifiable tombstone named, and
|
|
7814
|
+
* `chain` entries whose row is gone.
|
|
7815
|
+
*
|
|
7816
|
+
* Diffed against `before`, the snapshot the derivation was computed from, NOT the live store:
|
|
7817
|
+
* the anchor is written out of band while the pass's reads are in flight (an invitation pin,
|
|
7818
|
+
* the genesis anchor, a claim, a seed), and such a key is outside `target` only because the
|
|
7819
|
+
* pass never saw it — judging the live store would remove it. Likewise a key removed
|
|
7820
|
+
* meanwhile (a claim rollback) must not come back as `chain`. Both are judged by the next
|
|
7821
|
+
* pass. Each change lands in memory synchronously (`TrustedOwnerStore.trust` / `remove`); a
|
|
7822
|
+
* failed PERSIST is logged and does not stop the pass or fail the refresh, as
|
|
7823
|
+
* `SeedBootstrapService.anchorAcceptedSigner` treats the same failure.
|
|
7824
|
+
*/
|
|
7825
|
+
private async applyOwnerAnchor(
|
|
7826
|
+
store: TrustedOwnerStore,
|
|
7827
|
+
before: ReadonlyMap<string, TrustSource>,
|
|
7828
|
+
target: ReadonlySet<string>,
|
|
7829
|
+
reason: string,
|
|
7830
|
+
): Promise<void> {
|
|
7831
|
+
for (const key of target) {
|
|
7832
|
+
if (!before.has(key) && !store.has(key)) {
|
|
7833
|
+
log('syncOwnerAnchor(%s): owner %s derived from the OwnerKey table', reason, key);
|
|
7834
|
+
await store.trust(key, 'chain').catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
|
|
7835
|
+
}
|
|
7836
|
+
}
|
|
7837
|
+
for (const key of before.keys()) {
|
|
7838
|
+
if (!target.has(key) && store.has(key)) {
|
|
7839
|
+
log('syncOwnerAnchor(%s): owner %s removed from the anchor', reason, key);
|
|
7840
|
+
await store.remove(key).catch(error => log('syncOwnerAnchor(%s): anchor persist failed for %s: %o', reason, key, error));
|
|
7841
|
+
}
|
|
7842
|
+
}
|
|
7843
|
+
}
|
|
7844
|
+
|
|
7845
|
+
/**
|
|
7846
|
+
* A per-refresh {@link InvitationChainSource}: the first call loads the chain through
|
|
7847
|
+
* {@link loadInvitationChain}, every later call shares that load.
|
|
7848
|
+
*/
|
|
7849
|
+
private invitationChainSource(controlDatabase: ControlDatabase, retry: boolean): InvitationChainSource {
|
|
7850
|
+
let loading: Promise<InvitationChain> | null = null;
|
|
7851
|
+
return () => (loading ??= this.loadInvitationChain(controlDatabase, retry));
|
|
7852
|
+
}
|
|
7853
|
+
|
|
7854
|
+
/**
|
|
7855
|
+
* The usage and invitation rows {@link hasAnchoredProof} and {@link syncOwnerAnchor}
|
|
7856
|
+
* resolve an invitation-admitted row through, keyed for lookup. Both reads honour `retry`
|
|
7857
|
+
* for {@link listAuthorizedMembers}' reason.
|
|
7858
|
+
*
|
|
7859
|
+
* NOTE: once invitations are the only admission route (ticket
|
|
7860
|
+
* `cadre-invitations-redeemable-by-any-member`), every cadre with a member not yet
|
|
7861
|
+
* re-vouched pays these two whole-table reads and three ed25519 verifies per row on every
|
|
7862
|
+
* membership refresh. Both tables are append-only and a verified chain never changes (expiry,
|
|
7863
|
+
* use count and withdrawal are not re-checked), so if the gate's admission deadline ever
|
|
7864
|
+
* shows this, memoise verified (row stamp → usage stamp) pairs per node and skip the reads
|
|
7865
|
+
* when every admitted row is already memoised.
|
|
7866
|
+
*/
|
|
7867
|
+
private async loadInvitationChain(controlDatabase: ControlDatabase, retry: boolean): Promise<InvitationChain> {
|
|
7868
|
+
const [usages, invites] = await Promise.all([
|
|
7869
|
+
controlDatabase.queryCadreInviteUsages(retry),
|
|
7870
|
+
controlDatabase.queryCadreInvites(retry),
|
|
7871
|
+
]);
|
|
7872
|
+
return {
|
|
7873
|
+
usages: new Map(usages.map(usage => [usage.usageStampId, usage])),
|
|
7874
|
+
invites: new Map(invites.map(invite => [invite.key, invite])),
|
|
7875
|
+
};
|
|
7876
|
+
}
|
|
7877
|
+
|
|
7878
|
+
/**
|
|
7879
|
+
* The owner-voucher half of {@link hasAnchoredProof}: complete voucher, `VouchOwner` in
|
|
7880
|
+
* the node-local anchor, signature valid over the row's (PeerId, StampId) voucher digest.
|
|
7401
7881
|
*
|
|
7402
7882
|
* NOTE: verifies the ed25519 signature on every call (no memo of already-
|
|
7403
7883
|
* verified (peerId, stampId, vouchSig) triples). Cadres are a handful of
|
|
@@ -7412,13 +7892,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7412
7892
|
if (!this.trustedOwnerStore?.has(row.vouchOwner)) {
|
|
7413
7893
|
return false;
|
|
7414
7894
|
}
|
|
7415
|
-
return verifyCadrePeerVoucher(row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
|
|
7895
|
+
return verifyCadrePeerVoucher(this.partyId, row.peerId, row.stampId, row.vouchOwner, row.vouchSig);
|
|
7416
7896
|
}
|
|
7417
7897
|
|
|
7418
7898
|
/**
|
|
7419
7899
|
* Probe whether a given peer is an AUTHORIZED party member (see
|
|
7420
|
-
* {@link listAuthorizedMembers}) — the
|
|
7421
|
-
* strand-address
|
|
7900
|
+
* {@link listAuthorizedMembers}) — the check the protocol guard runs ahead of the
|
|
7901
|
+
* control-network wake and strand-address handlers, NOT {@link isMember} (the addressable
|
|
7422
7902
|
* surface). Deliberately scans the full membership (one `CadrePeer` query)
|
|
7423
7903
|
* rather than adding a single-row read path: cadres are small, and one code
|
|
7424
7904
|
* path keeps the predicate impossible to drift from the list.
|
|
@@ -7435,14 +7915,16 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7435
7915
|
* record (via {@link resolvePeerAddrs}, signaling/relay first — so a NAT'd peer
|
|
7436
7916
|
* is reachable through its circuit-relay address), dials `WAKE_PROTOCOL`, sends
|
|
7437
7917
|
* the {@link WakeRequest}, and returns the peer's {@link WakeAck}. The receiver
|
|
7438
|
-
*
|
|
7439
|
-
*
|
|
7440
|
-
*
|
|
7918
|
+
* only resumes a strand it already participates in; it acks once it has decided,
|
|
7919
|
+
* before the strand is up. The dial deadlines derive from this node's
|
|
7920
|
+
* `network.linkRoundTripMs`.
|
|
7441
7921
|
*
|
|
7442
7922
|
* @param targetPeerId - The hibernating cadre peer to wake.
|
|
7443
7923
|
* @param strandId - The strand the caller knows has pending activity.
|
|
7444
7924
|
* @param reason - Optional cause hint, e.g. `"activity"` or `"manual"`.
|
|
7445
|
-
* @throws if the node is not started
|
|
7925
|
+
* @throws if the node is not started, the target has no dialable address, or no
|
|
7926
|
+
* attempt got an ack — including when the receiver does not count this node as an
|
|
7927
|
+
* authorized member, since its protocol guard resets the stream without a reply.
|
|
7446
7928
|
*/
|
|
7447
7929
|
async pushWake(targetPeerId: string, strandId: string, reason?: string): Promise<WakeAck> {
|
|
7448
7930
|
if (!this.controlNode) {
|
|
@@ -7459,7 +7941,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7459
7941
|
/**
|
|
7460
7942
|
* Enable the seed listener for receiving seeds via the /sereus/seed/1.0.0 protocol.
|
|
7461
7943
|
* This is for drone nodes that need to receive seeds without being an owner.
|
|
7462
|
-
* Does not require an owner key.
|
|
7944
|
+
* Does not require an owner key. Idempotent; a node started with `config.claim`
|
|
7945
|
+
* calls it itself inside {@link start}, so an embedder's own call is a no-op.
|
|
7463
7946
|
*/
|
|
7464
7947
|
async enableSeedListener(): Promise<void> {
|
|
7465
7948
|
if (!this.controlNode || !this.controlDatabase) {
|
|
@@ -7475,9 +7958,8 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7475
7958
|
await this.installSeedBootstrapService(new SeedBootstrapService({
|
|
7476
7959
|
partyId: this.config.controlNetwork.partyId,
|
|
7477
7960
|
// No owner key - this node only receives seeds
|
|
7478
|
-
inviteAddressResolver: () => this.resolveInviteAddresses(),
|
|
7479
7961
|
...this.seedServiceBudgets(),
|
|
7480
|
-
trustPolicy: this.
|
|
7962
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
7481
7963
|
// A listener-only node accepts a wire-delivered seed solely against this
|
|
7482
7964
|
// anchor (there is no per-call override on the inbound handler): with no
|
|
7483
7965
|
// genesis/invite/operator pin it authorizes nobody, which is the point.
|
|
@@ -7546,6 +8028,18 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7546
8028
|
};
|
|
7547
8029
|
}
|
|
7548
8030
|
|
|
8031
|
+
/**
|
|
8032
|
+
* The node-wide default trust policy for INBOUND seeds, the one value every
|
|
8033
|
+
* {@link SeedBootstrapService} this node constructs is given — the owner service,
|
|
8034
|
+
* the listener, and `applySeed`'s temp service — so the three cannot drift. The
|
|
8035
|
+
* claim policy when the node was started with `config.claim` (the two are refused
|
|
8036
|
+
* together at start, so there is nothing to merge), otherwise the configured
|
|
8037
|
+
* `seedTrustPolicy`, and `undefined` for the service's anchored default.
|
|
8038
|
+
*/
|
|
8039
|
+
private inboundSeedTrustPolicy(): SeedTrustPolicy | undefined {
|
|
8040
|
+
return this.claimTrustPolicy ?? this.config.seedTrustPolicy;
|
|
8041
|
+
}
|
|
8042
|
+
|
|
7549
8043
|
/**
|
|
7550
8044
|
* Re-materialize the authorized-peer snapshot that the fail-closed per-stream
|
|
7551
8045
|
* control-DB gate ({@link authorizeInboundControlStream}) judges against.
|
|
@@ -7703,6 +8197,48 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7703
8197
|
});
|
|
7704
8198
|
}
|
|
7705
8199
|
|
|
8200
|
+
/**
|
|
8201
|
+
* Seat another owner key, signed by this node's owner key (the signed-add branch of
|
|
8202
|
+
* `OwnerKey.Authorized`; the row stores the vouching pair). The write notifies the
|
|
8203
|
+
* membership hub, so the refresh it triggers has run by the time this resolves.
|
|
8204
|
+
*
|
|
8205
|
+
* @param ownerPublicKeyB64 - the new owner's ed25519 public key (base64url).
|
|
8206
|
+
* @returns `true` when the row was seated, `false` when the key is already a live owner.
|
|
8207
|
+
*/
|
|
8208
|
+
async addOwner(ownerPublicKeyB64: string): Promise<boolean> {
|
|
8209
|
+
if (!this.seedBootstrapService) {
|
|
8210
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8211
|
+
}
|
|
8212
|
+
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
8213
|
+
const added = await this.seedBootstrapService.addOwner(key);
|
|
8214
|
+
// NOTE: an OwnerKey add committed alone is local-only and nothing re-issues it (no
|
|
8215
|
+
// update branch to re-touch, unlike CadrePeer), so it is only logged;
|
|
8216
|
+
// tickets/backlog/debt-owner-key-add-while-alone-has-no-reissue.md owns the fix.
|
|
8217
|
+
if (added && this.committedAlone()) {
|
|
8218
|
+
log('addOwner(%s) committed while ALONE (0 control connections): the new owner row is ' +
|
|
8219
|
+
'local-only and is not re-issued on cohort growth, so other machines may never see it.', key);
|
|
8220
|
+
}
|
|
8221
|
+
return added;
|
|
8222
|
+
}
|
|
8223
|
+
|
|
8224
|
+
/**
|
|
8225
|
+
* Remove an owner key, signed by this node's owner key (which must be a different owner):
|
|
8226
|
+
* the delete and its `Revocation` tombstone commit together. Refused by name when the key
|
|
8227
|
+
* is this node's own or the only live owner. The write notifies the membership hub; the
|
|
8228
|
+
* tombstone rides the committed-delete seam ({@link noteGuardedDelete}), so one committed
|
|
8229
|
+
* while alone is re-issued on cohort growth like every other removal.
|
|
8230
|
+
*
|
|
8231
|
+
* @param ownerPublicKeyB64 - the owner's ed25519 public key (base64url).
|
|
8232
|
+
* @returns `true` when the row was removed, `false` when the key is not a live owner here.
|
|
8233
|
+
*/
|
|
8234
|
+
async removeOwner(ownerPublicKeyB64: string): Promise<boolean> {
|
|
8235
|
+
if (!this.seedBootstrapService) {
|
|
8236
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8237
|
+
}
|
|
8238
|
+
const key = requireEd25519PublicKeyB64(ownerPublicKeyB64, 'owner key');
|
|
8239
|
+
return await this.seedBootstrapService.removeOwner(key);
|
|
8240
|
+
}
|
|
8241
|
+
|
|
7706
8242
|
/**
|
|
7707
8243
|
* Create a seed from the current control network state.
|
|
7708
8244
|
* The seed contains peer information and is signed by an owner.
|
|
@@ -7718,10 +8254,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7718
8254
|
* Apply a seed to populate the peer cache and enable connections.
|
|
7719
8255
|
*
|
|
7720
8256
|
* Validates the seed signature, then evaluates a trust anchor for the signer
|
|
7721
|
-
* key (see `SeedTrustPolicy`). An
|
|
7722
|
-
* `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a
|
|
7723
|
-
*
|
|
7724
|
-
*
|
|
8257
|
+
* key (see `SeedTrustPolicy`). An operator-driven caller can pass a per-seed
|
|
8258
|
+
* `trustPolicy` override — e.g. a `pinnedKeyTrustPolicy` built from a pinned
|
|
8259
|
+
* owner key — so a cold-start node can accept its first seed without
|
|
8260
|
+
* reconfiguring the service. The override wins on a node started with
|
|
8261
|
+
* `config.claim` as well: the claim secret is the node's default inbound policy,
|
|
8262
|
+
* not a lock on its local operator, who already controls the process.
|
|
7725
8263
|
*/
|
|
7726
8264
|
async applySeed(
|
|
7727
8265
|
seed: ControlNetworkSeed,
|
|
@@ -7735,13 +8273,13 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7735
8273
|
//
|
|
7736
8274
|
// This temp service is discarded after the call, so it must NOT own the shared
|
|
7737
8275
|
// node's inbound seed handler: pass { registerHandler: false }. That keeps
|
|
7738
|
-
// repeated service-less applySeed
|
|
8276
|
+
// repeated service-less applySeed idempotent (no handler leak, no
|
|
7739
8277
|
// DuplicateProtocolHandlerError). The temp service still applies this seed; a
|
|
7740
8278
|
// node that wants to RECEIVE inbound seeds needs a persistent service
|
|
7741
8279
|
// (enableSeedListener / initializeSeedBootstrap) to own the handler.
|
|
7742
8280
|
const tempService = new SeedBootstrapService({
|
|
7743
8281
|
partyId: seed.partyId,
|
|
7744
|
-
trustPolicy: this.
|
|
8282
|
+
trustPolicy: this.inboundSeedTrustPolicy(),
|
|
7745
8283
|
...this.seedServiceBudgets(),
|
|
7746
8284
|
// The anchor is node-scoped, not service-scoped: a throwaway service
|
|
7747
8285
|
// must consult (and persist an accepted signer into) the SAME store the
|
|
@@ -7784,12 +8322,23 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7784
8322
|
|
|
7785
8323
|
/**
|
|
7786
8324
|
* Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
|
|
8325
|
+
*
|
|
8326
|
+
* `target` is a multiaddr string, or a peer id with its addresses (the form a phone
|
|
8327
|
+
* uses for a node it scanned), which are dialed one at a time before the request
|
|
8328
|
+
* (`SeedBootstrapService.deliverSeed`). `options.claimProof` rides beside the seed when
|
|
8329
|
+
* the target is a brand-new node being claimed (`claim-proof.ts`).
|
|
8330
|
+
*
|
|
8331
|
+
* @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it formed.
|
|
7787
8332
|
*/
|
|
7788
|
-
async deliverSeed(
|
|
8333
|
+
async deliverSeed(
|
|
8334
|
+
target: SeedDeliveryTarget,
|
|
8335
|
+
seed: ControlNetworkSeed,
|
|
8336
|
+
options?: { claimProof?: string },
|
|
8337
|
+
): Promise<SeedAckMessage> {
|
|
7789
8338
|
if (!this.seedBootstrapService) {
|
|
7790
8339
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7791
8340
|
}
|
|
7792
|
-
return await this.seedBootstrapService.deliverSeed(
|
|
8341
|
+
return await this.seedBootstrapService.deliverSeed(target, seed, options);
|
|
7793
8342
|
}
|
|
7794
8343
|
|
|
7795
8344
|
/**
|
|
@@ -7862,32 +8411,63 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7862
8411
|
}
|
|
7863
8412
|
|
|
7864
8413
|
/**
|
|
7865
|
-
*
|
|
7866
|
-
*
|
|
8414
|
+
* Claim a node that was started with a claim secret (`CadreNodeConfig.claim`), from
|
|
8415
|
+
* the peer id, addresses and secret its host showed (the QR code): mint a seed, prove
|
|
8416
|
+
* possession of the secret beside it (`claim-proof.ts`), deliver it, and on acceptance
|
|
8417
|
+
* vouch the node into this cadre and keep its addresses as a dial target. The owner
|
|
8418
|
+
* side of the claim-secret entry in the Seed Delivery Protocol (`docs/architecture.md`).
|
|
8419
|
+
*
|
|
8420
|
+
* Requires the seed service with an owner key ({@link initializeSeedBootstrap}), as
|
|
8421
|
+
* {@link addDrone} does. The order is the REVERSE of `addDrone`'s: seed first, row
|
|
8422
|
+
* second. A refused claim or a failed delivery therefore leaves no `CadrePeer` row and
|
|
8423
|
+
* no retained dial target behind on this node — a phone that typed the wrong secret,
|
|
8424
|
+
* or scanned a node someone else already owns, has nothing to clean up.
|
|
8425
|
+
*
|
|
8426
|
+
* A failure is one of three kinds a caller can tell apart:
|
|
8427
|
+
*
|
|
8428
|
+
* - `ClaimRefusedError` — the node was reached and refused, with its refusal code when
|
|
8429
|
+
* its policy gave one.
|
|
8430
|
+
* - `PeerUnreachableError` — no connection to the node formed from any of its addresses,
|
|
8431
|
+
* so nothing was sent. Each address is dialed in turn on its own limit, so this can
|
|
8432
|
+
* take up to the per-peer dial limit plus the seed request's (114.5 s at the default
|
|
8433
|
+
* declared link) when none answers.
|
|
8434
|
+
* - anything else — the node was reached and the exchange failed, or a local step failed
|
|
8435
|
+
* (a malformed secret or address among them). Repeating the claim with the same secret
|
|
8436
|
+
* is safe (see below).
|
|
8437
|
+
*
|
|
8438
|
+
* Idempotent for the same owner: a second call after a dropped response is accepted
|
|
8439
|
+
* by the node (its anchored branch), the row insert is a no-op on an existing row
|
|
8440
|
+
* (`ControlDatabase.insertCadrePeer`), and the dial target is replaced, not duplicated.
|
|
8441
|
+
*
|
|
8442
|
+
* The first dial is kicked (not awaited) so it happens now rather than on the next
|
|
8443
|
+
* timed reconcile pass; a caller that wants to wait for the connection awaits
|
|
8444
|
+
* {@link reconcileControlCohort} itself, with `addDrone`'s caveat that a pass already
|
|
8445
|
+
* in flight is joined rather than restarted. The claimed node dials this one back too,
|
|
8446
|
+
* from the seed's owner list, when this node has a listen or relay address; a phone
|
|
8447
|
+
* with neither is reached by its own reconcile pass alone.
|
|
8448
|
+
*
|
|
8449
|
+
* @throws {ClaimRefusedError} when the node was reached and refused the claim.
|
|
8450
|
+
* @throws {PeerUnreachableError} when the node was never reached.
|
|
7867
8451
|
*/
|
|
7868
|
-
async
|
|
8452
|
+
async claimNode(target: { peerId: string; multiaddrs: string[]; secret: string }): Promise<void> {
|
|
7869
8453
|
if (!this.seedBootstrapService) {
|
|
7870
8454
|
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7871
8455
|
}
|
|
7872
|
-
const
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
/**
|
|
7881
|
-
* Accept a phone connection using an invite.
|
|
7882
|
-
* Call this when a phone dials in with an invite token.
|
|
7883
|
-
*/
|
|
7884
|
-
async acceptPhone(options: AddPhoneOptions, issuedInvite?: CadreInvite): Promise<void> {
|
|
7885
|
-
if (!this.seedBootstrapService) {
|
|
7886
|
-
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
8456
|
+
const secret = parseClaimSecret(target.secret);
|
|
8457
|
+
const seed = await this.seedBootstrapService.createSeed();
|
|
8458
|
+
const proof = claimProof(secret, target.peerId, seed.signerKey, seedDigest(seed));
|
|
8459
|
+
const ack = await this.seedBootstrapService.deliverSeed(
|
|
8460
|
+
{ peerId: target.peerId, multiaddrs: target.multiaddrs }, seed, { claimProof: proof });
|
|
8461
|
+
if (!ack.accepted) {
|
|
8462
|
+
throw new ClaimRefusedError(target.peerId, ack.reason ?? 'no reason given', ack.code);
|
|
7887
8463
|
}
|
|
7888
|
-
|
|
7889
|
-
//
|
|
7890
|
-
|
|
8464
|
+
log('claimNode: %s accepted the claim; vouching it into the cadre', target.peerId);
|
|
8465
|
+
// The wrapper queues the write for re-replication: a phone claiming its first
|
|
8466
|
+
// always-on node has no control connection yet, so the insert commits local-only.
|
|
8467
|
+
await this.authorizePeer(target.peerId, target.multiaddrs);
|
|
8468
|
+
this.retainDialTarget(target.peerId, target.multiaddrs, 'claimNode');
|
|
8469
|
+
void this.reconcileControlCohort().catch((error) =>
|
|
8470
|
+
log('claimNode: reconcile kick after claiming %s failed: %o', target.peerId, error));
|
|
7891
8471
|
}
|
|
7892
8472
|
|
|
7893
8473
|
/**
|
|
@@ -7905,47 +8485,187 @@ export class CadreNode implements SAppIdLookup {
|
|
|
7905
8485
|
return await this.seedBootstrapService.addPhoneWithRelay(phonePeerId);
|
|
7906
8486
|
}
|
|
7907
8487
|
|
|
8488
|
+
// ============================================================================
|
|
8489
|
+
// Cadre Invitation API (redeemable at any member, `/sereus/cadre-invite/1.0.0`)
|
|
8490
|
+
// ============================================================================
|
|
8491
|
+
|
|
7908
8492
|
/**
|
|
7909
|
-
*
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
|
|
7913
|
-
|
|
8493
|
+
* Mint an owner-signed cadre invitation that a device can redeem at ANY member machine of
|
|
8494
|
+
* this party, the owner offline included ({@link redeemCadreInvitation}). Requires the
|
|
8495
|
+
* owner key ({@link initializeSeedBootstrap}).
|
|
8496
|
+
*
|
|
8497
|
+
* The invitation is an ed25519 keypair: the public half is the `CadreInvite` row's key,
|
|
8498
|
+
* the private half rides in the returned bundle as the proof of possession. Whoever holds
|
|
8499
|
+
* the bundle can redeem it, so an UNTARGETED, OWNER-GRANTING invitation — a bearer
|
|
8500
|
+
* credential for admin rights — defaults to a 15-minute lifetime; every other kind to 24
|
|
8501
|
+
* hours. One use unless `uses` says otherwise. The bundle carries the signed row (so a
|
|
8502
|
+
* member that has not received it by replication seats it from the bundle), this node's
|
|
8503
|
+
* anchored owner keys — every current owner this node has verified, the out-of-band ones
|
|
8504
|
+
* and those derived along verifiable chains ({@link syncOwnerAnchor}) — (the device pins
|
|
8505
|
+
* them and checks the member's reply against them; sourced from the node-local anchor
|
|
8506
|
+
* only, never the raw replicated `OwnerKey` table, because the device anchors whatever
|
|
8507
|
+
* arrives and a stranger's genesis-inserted key must not ride an invitation into a fresh
|
|
8508
|
+
* node's anchor; an empty anchor is refused because a reply could not be checked), and
|
|
8509
|
+
* the addresses of this machine first
|
|
8510
|
+
* ({@link resolveInviteAddresses}, which honours pushed NAT addresses) then up to three
|
|
8511
|
+
* other members ({@link siblingInvitationAddrs}).
|
|
8512
|
+
*
|
|
8513
|
+
* The row commits locally and replicates like any other control write; minted while
|
|
8514
|
+
* alone (a phone with no connection), it reaches the other members through the
|
|
8515
|
+
* peer-join block catch-up on the next connection. The device can redeem at any member it
|
|
8516
|
+
* reaches meanwhile: a member that does not hold the row yet admits the device's connection
|
|
8517
|
+
* provisionally ({@link admitInboundControlConnection}) and seats the row from the bundle.
|
|
8518
|
+
*
|
|
8519
|
+
* @throws when no owner key is wired, the anchor is empty, `uses` is not a positive
|
|
8520
|
+
* integer, or neither this machine nor any other member has an address.
|
|
8521
|
+
*/
|
|
8522
|
+
async createCadreInvitation(options: CreateCadreInvitationOptions): Promise<CreateCadreInvitationResult> {
|
|
8523
|
+
const service = this.seedBootstrapService;
|
|
8524
|
+
if (!service?.canAuthorize()) {
|
|
8525
|
+
throw new Error('Seed bootstrap service not initialized with an owner key. Call initializeSeedBootstrap() first.');
|
|
8526
|
+
}
|
|
8527
|
+
const ownerKeys = Array.from(this.trustedOwnerStore?.all() ?? []);
|
|
8528
|
+
if (ownerKeys.length === 0) {
|
|
8529
|
+
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');
|
|
8530
|
+
}
|
|
8531
|
+
const uses = options.uses ?? 1;
|
|
8532
|
+
if (!Number.isInteger(uses) || uses < 1) {
|
|
8533
|
+
throw new Error(`createCadreInvitation: uses must be a positive integer (received ${String(options.uses)})`);
|
|
8534
|
+
}
|
|
8535
|
+
const members = [...await this.resolveInviteAddresses(), ...await this.siblingInvitationAddrs()];
|
|
8536
|
+
if (members.length === 0) {
|
|
8537
|
+
throw new Error('createCadreInvitation: neither this machine nor any other machine of the party has an address a device could dial');
|
|
8538
|
+
}
|
|
8539
|
+
const expiresInMs = options.expiresInMs
|
|
8540
|
+
?? (options.grantsOwner && !options.peerId ? OWNER_INVITATION_DEFAULT_TTL_MS : CADRE_INVITATION_DEFAULT_TTL_MS);
|
|
8541
|
+
const invitePrivateKey = generatePrivateKey('ed25519', 'base64url') as string;
|
|
8542
|
+
const row = await service.insertCadreInvite({
|
|
8543
|
+
key: ed25519PublicKeyFromPrivate(invitePrivateKey),
|
|
8544
|
+
peerId: options.peerId ?? null,
|
|
8545
|
+
grantsOwner: options.grantsOwner,
|
|
8546
|
+
expiresAtMs: Date.now() + expiresInMs,
|
|
8547
|
+
totalUses: uses,
|
|
8548
|
+
});
|
|
8549
|
+
const invitation: CadreInvitation = {
|
|
8550
|
+
v: 1,
|
|
8551
|
+
partyId: this.config.controlNetwork.partyId,
|
|
8552
|
+
invitePrivateKey,
|
|
8553
|
+
invite: row,
|
|
8554
|
+
ownerKeys,
|
|
8555
|
+
members,
|
|
8556
|
+
};
|
|
8557
|
+
log('Cadre invitation minted: %s (peer=%s owner=%s uses=%d, %d member address(es))',
|
|
8558
|
+
row.key, row.peerId ?? 'any', row.grantsOwner, uses, members.length);
|
|
8559
|
+
return { invitation, encoded: encodeCadreInvitation(invitation) };
|
|
7914
8560
|
}
|
|
7915
8561
|
|
|
7916
8562
|
/**
|
|
7917
|
-
*
|
|
8563
|
+
* Every cadre invitation this node holds — minted here or replicated in — with its
|
|
8564
|
+
* standing ({@link CadreInviteStatus}: live, withdrawn, redemptions recorded).
|
|
7918
8565
|
*/
|
|
7919
|
-
|
|
7920
|
-
|
|
7921
|
-
|
|
7922
|
-
|
|
8566
|
+
async listCadreInvitations(): Promise<CadreInviteStatus[]> {
|
|
8567
|
+
if (!this.controlDatabase) {
|
|
8568
|
+
throw new Error('CadreNode must be started before listing cadre invitations');
|
|
8569
|
+
}
|
|
8570
|
+
return await this.controlDatabase.listCadreInviteStatuses();
|
|
7923
8571
|
}
|
|
7924
8572
|
|
|
7925
8573
|
/**
|
|
7926
|
-
*
|
|
8574
|
+
* Withdraw a cadre invitation: an owner-signed `Revocation` tombstone over its row, which
|
|
8575
|
+
* stays as the proof of membership for every device it admitted. No further redemption
|
|
8576
|
+
* succeeds anywhere the tombstone reaches, and a member holding it refuses the bundle's
|
|
8577
|
+
* copy of the row. A withdrawal committed while alone is re-issued on cohort growth like
|
|
8578
|
+
* any other tombstone.
|
|
8579
|
+
*
|
|
8580
|
+
* @returns `true` when this call filed the tombstone, `false` when the row is not held
|
|
8581
|
+
* here or was already withdrawn.
|
|
7927
8582
|
*/
|
|
7928
|
-
async
|
|
8583
|
+
async withdrawCadreInvitation(key: string): Promise<boolean> {
|
|
7929
8584
|
if (!this.seedBootstrapService) {
|
|
7930
|
-
|
|
7931
|
-
// the call, so it does NOT own the shared node's inbound seed handler:
|
|
7932
|
-
// initialize with { registerHandler: false }. This temp dialInvite never
|
|
7933
|
-
// applies a seed itself — a node that wants to RECEIVE an inbound seed back
|
|
7934
|
-
// must have a persistent service (enableSeedListener / initializeSeedBootstrap)
|
|
7935
|
-
// own the /sereus/seed/1.0.0 handler. trustPolicy is therefore effectively dead
|
|
7936
|
-
// on this dial-only path; it is kept for symmetry with the applySeed temp site.
|
|
7937
|
-
const tempService = new SeedBootstrapService({
|
|
7938
|
-
partyId: invite.partyId,
|
|
7939
|
-
trustPolicy: this.config.seedTrustPolicy,
|
|
7940
|
-
...this.seedServiceBudgets(),
|
|
7941
|
-
});
|
|
7942
|
-
if (this.controlNode && this.controlDatabase) {
|
|
7943
|
-
await tempService.initialize(this.controlNode, this.controlDatabase, { registerHandler: false });
|
|
7944
|
-
}
|
|
7945
|
-
await tempService.dialInvite(invite);
|
|
7946
|
-
return;
|
|
8585
|
+
throw new Error('Seed bootstrap service not initialized. Call initializeSeedBootstrap() first.');
|
|
7947
8586
|
}
|
|
7948
|
-
await this.seedBootstrapService.
|
|
8587
|
+
return await this.seedBootstrapService.withdrawCadreInvite(key);
|
|
8588
|
+
}
|
|
8589
|
+
|
|
8590
|
+
/**
|
|
8591
|
+
* Join the cadre an invitation names by redeeming it at one of the member machines it
|
|
8592
|
+
* lists, the owner that minted it offline or not. Needs a started node with a stable
|
|
8593
|
+
* identity (`keyStore` or `privateKey`): the redemption is signed with the key behind
|
|
8594
|
+
* this node's peer id, and the member checks that the two match.
|
|
8595
|
+
*
|
|
8596
|
+
* Steps: refuse an invitation for another party; pin its `ownerKeys` into the node-local
|
|
8597
|
+
* anchor (`'invite'` provenance — the pin sticks even if the redemption then fails, as it
|
|
8598
|
+
* does for a seed invite, so a later attempt or seed from the same owner is anchored);
|
|
8599
|
+
* sign the request; dial the listed members in order ({@link redeemAtMembers}); on
|
|
8600
|
+
* acceptance verify the reply against the pinned keys, merge its dial hints into the peer
|
|
8601
|
+
* store, retain the answering member as a cold-start dial target, refresh the membership
|
|
8602
|
+
* gate and start a cohort reconcile pass. This node then syncs the control database over
|
|
8603
|
+
* the connection it already holds, the same unified behaviour as after a seed.
|
|
8604
|
+
*
|
|
8605
|
+
* A device that is already a member (a restart with the invitation still configured) is
|
|
8606
|
+
* answered as accepted by the member's idempotent redemption, with no second usage row.
|
|
8607
|
+
*
|
|
8608
|
+
* When the invitation grants ownership, the member seats this node's key as an `OwnerKey`
|
|
8609
|
+
* row by consent. The caller still has to wire that key for signing afterwards —
|
|
8610
|
+
* `initializeSeedBootstrap(ownKey)`, which anchors it; never `ensureOwnerKey`, the row is
|
|
8611
|
+
* already there. Other machines derive this node's key into their anchors from the row's
|
|
8612
|
+
* stored proof on their next membership refresh ({@link syncOwnerAnchor}).
|
|
8613
|
+
*
|
|
8614
|
+
* Limit: the reply check ({@link verifyRedeemReply}) does not authenticate the member. A
|
|
8615
|
+
* forged bundle, or a legitimate one whose addresses were swapped, dials a machine that
|
|
8616
|
+
* can echo the owner-signed row it was just sent; that is what pasting an attacker's
|
|
8617
|
+
* invitation means. What protects this node afterwards is its anchor: every row it then
|
|
8618
|
+
* syncs is judged against the pinned owner keys, so a machine that is not a member of the
|
|
8619
|
+
* cadre the invitation names can admit nobody this node will trust.
|
|
8620
|
+
*
|
|
8621
|
+
* @throws `CadreInviteRejectedError` on a final refusal, `CadreInviteReplyInvalidError` on
|
|
8622
|
+
* an acceptance that fails the reply check, `CadreInviteUnreachableError` when every
|
|
8623
|
+
* listed address was tried without an acceptance, and a plain `Error` for the
|
|
8624
|
+
* preconditions above.
|
|
8625
|
+
*/
|
|
8626
|
+
async redeemCadreInvitation(invitation: CadreInvitation): Promise<RedeemCadreInvitationResult> {
|
|
8627
|
+
const controlNode = this.controlNode;
|
|
8628
|
+
if (!controlNode || !this.controlDatabase || !this._running) {
|
|
8629
|
+
throw new Error('CadreNode must be started before redeeming a cadre invitation');
|
|
8630
|
+
}
|
|
8631
|
+
if (invitation.partyId !== this.config.controlNetwork.partyId) {
|
|
8632
|
+
throw new Error(`Cadre invitation is for party ${invitation.partyId}; this node serves ${this.config.controlNetwork.partyId}`);
|
|
8633
|
+
}
|
|
8634
|
+
const signer = this.getSelfSigningKey();
|
|
8635
|
+
if (!signer) {
|
|
8636
|
+
throw new Error('redeemCadreInvitation: this node runs on an ephemeral identity; configure `keyStore` or `privateKey` so its key can sign the redemption');
|
|
8637
|
+
}
|
|
8638
|
+
await this.trustOwnerKeys(invitation.ownerKeys, 'invite');
|
|
8639
|
+
const pinned = new Set(invitation.ownerKeys);
|
|
8640
|
+
const request = signRedeemRequest(
|
|
8641
|
+
invitation,
|
|
8642
|
+
{ peerKey: signer.publicKeyB64, peerPrivateKey: signer.privateKeyB64 },
|
|
8643
|
+
generateStampId(controlNode.peerId.toString()),
|
|
8644
|
+
await this.resolveInviteAddresses()
|
|
8645
|
+
);
|
|
8646
|
+
const { reply, memberPeerId, memberAddr } = await redeemAtMembers(controlNode, {
|
|
8647
|
+
invitation,
|
|
8648
|
+
request,
|
|
8649
|
+
isTrustedIssuer: (ownerKey) => pinned.has(ownerKey),
|
|
8650
|
+
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
8651
|
+
});
|
|
8652
|
+
log('Cadre invitation %s redeemed at %s (owner=%s)', invitation.invite.key, memberAddr, reply.invite.grantsOwner);
|
|
8653
|
+
|
|
8654
|
+
const selfPeerId = controlNode.peerId.toString();
|
|
8655
|
+
await mergeSeedPeers(controlNode, reply.peers.filter((peer) => peer.peerId !== selfPeerId));
|
|
8656
|
+
if (memberPeerId !== null) {
|
|
8657
|
+
// Every address the bundle gave the answering member, so a restart has the same choices.
|
|
8658
|
+
const memberAddrs = this.parseMultiaddrs(invitation.members)
|
|
8659
|
+
.filter((addr) => trailingPeerId(addr) === memberPeerId)
|
|
8660
|
+
.map((addr) => addr.toString());
|
|
8661
|
+
this.retainDialTarget(memberPeerId, memberAddrs, 'redeemCadreInvitation');
|
|
8662
|
+
}
|
|
8663
|
+
// The pins above flip nothing yet (this node holds no rows), but the rows about to
|
|
8664
|
+
// replicate in are judged against them; refresh so the snapshot is not stale when they land.
|
|
8665
|
+
await this.refreshMembershipGate('invitation-redeemed');
|
|
8666
|
+
void this.reconcileControlCohort().catch((error) =>
|
|
8667
|
+
log('redeemCadreInvitation: the post-redemption reconcile pass failed: %o', error));
|
|
8668
|
+
return { peerId: memberPeerId, grantsOwner: reply.invite.grantsOwner, redeemedAt: new Date().toISOString() };
|
|
7949
8669
|
}
|
|
7950
8670
|
|
|
7951
8671
|
// ============================================================================
|
|
@@ -8091,10 +8811,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8091
8811
|
}
|
|
8092
8812
|
|
|
8093
8813
|
/**
|
|
8094
|
-
* Addresses of the party's other machines for an invitation's bootstrap list
|
|
8095
|
-
* ({@link
|
|
8096
|
-
*
|
|
8097
|
-
* is
|
|
8814
|
+
* Addresses of the party's other machines for an invitation's bootstrap list — an open
|
|
8815
|
+
* strand invitation's ({@link createOpenInvitation}) or a cadre invitation's
|
|
8816
|
+
* ({@link createCadreInvitation}); {@link selectInvitationSiblingAddrs} picks which. The
|
|
8817
|
+
* source is each authorized member's signed `CadrePeer` record ({@link resolvePeerRecord}),
|
|
8818
|
+
* so a machine whose record is missing, stale or untrusted is left out; live connections
|
|
8819
|
+
* only order them.
|
|
8098
8820
|
*
|
|
8099
8821
|
* Best-effort: a failed read leaves the invitation naming this machine alone, which is what
|
|
8100
8822
|
* it named before siblings were added.
|
|
@@ -8115,7 +8837,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8115
8837
|
const connected = new Set(controlNode.getConnections().map((c) => c.remotePeer.toString()));
|
|
8116
8838
|
return selectInvitationSiblingAddrs(siblings, connected);
|
|
8117
8839
|
} catch (error) {
|
|
8118
|
-
log('
|
|
8840
|
+
log('siblingInvitationAddrs: resolving the other machines failed; the invitation names only this one: %o', error);
|
|
8119
8841
|
return [];
|
|
8120
8842
|
}
|
|
8121
8843
|
}
|
|
@@ -8163,7 +8885,7 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8163
8885
|
* Ask to join through `invitation` and have the party keep trying until the join works, the
|
|
8164
8886
|
* invitation is used up or expires, or the request is dismissed: across restarts, and from
|
|
8165
8887
|
* any owner machine of the party (`docs/strands.md` → "Joining while the inviter is
|
|
8166
|
-
* offline"). Records a party-wide `
|
|
8888
|
+
* offline"). Records a party-wide `JoinRequest` row, runs one attempt on this machine at
|
|
8167
8889
|
* once, and returns the status after it: `'joined'` when the inviter answered yes, `'waiting'`
|
|
8168
8890
|
* when it was unreachable or not ready, `'failed'` when it refused for good.
|
|
8169
8891
|
*
|
|
@@ -8238,17 +8960,17 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8238
8960
|
}
|
|
8239
8961
|
|
|
8240
8962
|
/**
|
|
8241
|
-
* Write a join request
|
|
8242
|
-
* pending one as it is, a finished one
|
|
8963
|
+
* Write a join request, or adopt the party's existing request for the same invitation: a
|
|
8964
|
+
* pending one as it is, a finished one rewritten as `requested` (the user asked again).
|
|
8243
8965
|
*/
|
|
8244
8966
|
private async recordJoinRequest(
|
|
8245
8967
|
database: ControlDatabase,
|
|
8246
|
-
requested:
|
|
8968
|
+
requested: JoinRequestFields,
|
|
8247
8969
|
{ ownerKey, signMessage }: { ownerKey: string; signMessage: (message: Uint8Array) => string }
|
|
8248
|
-
): Promise<
|
|
8249
|
-
let written:
|
|
8970
|
+
): Promise<PendingJoin> {
|
|
8971
|
+
let written: PendingJoin;
|
|
8250
8972
|
try {
|
|
8251
|
-
written = await database.
|
|
8973
|
+
written = await database.insertJoinRequest(requested, ownerKey, signMessage);
|
|
8252
8974
|
} catch (error) {
|
|
8253
8975
|
if (!isPendingJoinConflict(error)) {
|
|
8254
8976
|
throw error;
|
|
@@ -8261,12 +8983,12 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8261
8983
|
{ cause: error }
|
|
8262
8984
|
);
|
|
8263
8985
|
}
|
|
8264
|
-
if (existing.
|
|
8986
|
+
if (existing.outcome === null) {
|
|
8265
8987
|
return existing;
|
|
8266
8988
|
}
|
|
8267
|
-
written = await database.
|
|
8989
|
+
written = await database.rewritePendingJoin(existing.StampId, { ...requested, outcome: null }, ownerKey, signMessage);
|
|
8268
8990
|
}
|
|
8269
|
-
this.pendingJoinRunner?.noteWritten(written);
|
|
8991
|
+
this.pendingJoinRunner?.noteWritten(written, 'request');
|
|
8270
8992
|
return written;
|
|
8271
8993
|
}
|
|
8272
8994
|
|
|
@@ -8284,15 +9006,19 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8284
9006
|
selfId: this.controlNode!.peerId.toString(),
|
|
8285
9007
|
linkRoundTripMs: this.config.network?.linkRoundTripMs,
|
|
8286
9008
|
isOwner: async () => (await this.enrolledOwnerSigningKey()) !== null,
|
|
8287
|
-
// NOTE: one owner-key read and one
|
|
8288
|
-
// asked for a join the second reads
|
|
8289
|
-
// shows up in a device profile, poll more slowly while the
|
|
9009
|
+
// NOTE: one owner-key read and one pending-join read (three tables) per pass (30 s); for a
|
|
9010
|
+
// party that never asked for a join the second reads never-written blocks, which consults
|
|
9011
|
+
// the cohort. If it shows up in a device profile, poll more slowly while the tables read empty.
|
|
8290
9012
|
readRows: () => database().queryPendingJoins(),
|
|
8291
9013
|
readRow: (id) => database().queryPendingJoin(id),
|
|
8292
|
-
attempt: (
|
|
8293
|
-
|
|
9014
|
+
attempt: (join) => this.formStrand(this.pendingJoinInvitation(join), parseStoredDisclosure(join)),
|
|
9015
|
+
record: async (expected, outcome) => {
|
|
9016
|
+
const { ownerKey, signMessage } = await outcomeSigner();
|
|
9017
|
+
return database().recordJoinOutcome(expected, outcome, ownerKey, signMessage);
|
|
9018
|
+
},
|
|
9019
|
+
rewrite: async (expectedStampId, next) => {
|
|
8294
9020
|
const { ownerKey, signMessage } = await outcomeSigner();
|
|
8295
|
-
return database().
|
|
9021
|
+
return database().rewritePendingJoin(expectedStampId, next, ownerKey, signMessage);
|
|
8296
9022
|
},
|
|
8297
9023
|
remove: async (id) => {
|
|
8298
9024
|
const { ownerKey, signMessage } = await outcomeSigner();
|
|
@@ -8306,33 +9032,33 @@ export class CadreNode implements SAppIdLookup {
|
|
|
8306
9032
|
this.pendingJoinRunner.start();
|
|
8307
9033
|
}
|
|
8308
9034
|
|
|
8309
|
-
/** The invitation a `
|
|
8310
|
-
private pendingJoinInvitation(
|
|
9035
|
+
/** The invitation a `JoinRequest` row stores. A failure names the row, never the invitation, which is a bearer credential. */
|
|
9036
|
+
private pendingJoinInvitation(join: PendingJoin): OpenInvitation {
|
|
8311
9037
|
try {
|
|
8312
|
-
return this.decodeInvitation(
|
|
9038
|
+
return this.decodeInvitation(join.Invitation);
|
|
8313
9039
|
} catch {
|
|
8314
|
-
throw new Error(`
|
|
9040
|
+
throw new Error(`JoinRequest ${join.Id}: the stored invitation does not decode`);
|
|
8315
9041
|
}
|
|
8316
9042
|
}
|
|
8317
9043
|
|
|
8318
|
-
private pendingJoinSAppId(
|
|
9044
|
+
private pendingJoinSAppId(join: PendingJoin): string {
|
|
8319
9045
|
try {
|
|
8320
|
-
return this.decodeInvitation(
|
|
9046
|
+
return this.decodeInvitation(join.Invitation).sAppId;
|
|
8321
9047
|
} catch {
|
|
8322
|
-
log('
|
|
9048
|
+
log('JoinRequest %s: the stored invitation does not decode; reporting no sApp', join.Id);
|
|
8323
9049
|
return '';
|
|
8324
9050
|
}
|
|
8325
9051
|
}
|
|
8326
9052
|
|
|
8327
9053
|
/**
|
|
8328
|
-
* Stage the membership invitation of each
|
|
9054
|
+
* Stage the membership invitation of each joined request this process has not staged yet, so a
|
|
8329
9055
|
* closed strand joined on another owner machine is seated by whichever machine launches it
|
|
8330
9056
|
* first; the party key it admits is the replicated `StrandPartyKey` row the finishing machine
|
|
8331
9057
|
* seated. A strand that already has an invitation staged keeps it, for the reconciler to
|
|
8332
9058
|
* settle first.
|
|
8333
9059
|
*/
|
|
8334
|
-
private stageMembershipInvitesFromPendingJoins(
|
|
8335
|
-
for (const { rowId, strandId, invite } of membershipInvitesToStage(
|
|
9060
|
+
private stageMembershipInvitesFromPendingJoins(joins: readonly PendingJoin[]): void {
|
|
9061
|
+
for (const { rowId, strandId, invite } of membershipInvitesToStage(joins, this.stagedMembershipInviteKeys, Date.now())) {
|
|
8336
9062
|
this.stagedMembershipInviteKeys.add(invite.inviteKey);
|
|
8337
9063
|
if (this.pendingMembershipInvites.has(strandId)) {
|
|
8338
9064
|
continue;
|