@serfab/cadre-core 1.14.0 → 1.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -274
- package/dist/cadre-invite-protocol.d.ts +64 -29
- package/dist/cadre-invite-protocol.js +207 -38
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-join-request.d.ts +42 -0
- package/dist/cadre-join-request.js +144 -0
- package/dist/cadre-join-request.js.map +1 -0
- package/dist/cadre-node.d.ts +375 -79
- package/dist/cadre-node.js +758 -186
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +92 -12
- package/dist/control-database.js +323 -142
- package/dist/control-database.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +19 -3
- package/dist/control-schema.js.map +1 -1
- package/dist/ed25519-key.d.ts +7 -9
- package/dist/ed25519-key.js +20 -10
- package/dist/ed25519-key.js.map +1 -1
- package/dist/fetch-impl.d.ts +12 -0
- package/dist/fetch-impl.js +23 -0
- package/dist/fetch-impl.js.map +1 -0
- package/dist/file-durable-slot.js +2 -1
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/file-safe-name.d.ts +25 -0
- package/dist/file-safe-name.js +29 -0
- package/dist/file-safe-name.js.map +1 -0
- package/dist/formation-approval.js +5 -11
- package/dist/formation-approval.js.map +1 -1
- package/dist/fs-atomic.d.ts +0 -19
- package/dist/fs-atomic.js +3 -24
- package/dist/fs-atomic.js.map +1 -1
- package/dist/index.d.ts +12 -5
- package/dist/index.js +31 -6
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +50 -12
- package/dist/invitation-bootstrap.js +76 -13
- package/dist/invitation-bootstrap.js.map +1 -1
- package/dist/joined-strand-store.d.ts +7 -0
- package/dist/joined-strand-store.js +9 -0
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/key-store-file.js +2 -1
- package/dist/key-store-file.js.map +1 -1
- package/dist/link-budget.d.ts +9 -4
- package/dist/link-budget.js +9 -4
- package/dist/link-budget.js.map +1 -1
- package/dist/machine-removal.d.ts +78 -0
- package/dist/machine-removal.js +166 -0
- package/dist/machine-removal.js.map +1 -0
- package/dist/membership-connection-gater.d.ts +6 -5
- package/dist/membership-connection-gater.js +6 -5
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-claim-payload.d.ts +55 -0
- package/dist/node-claim-payload.js +108 -4
- package/dist/node-claim-payload.js.map +1 -1
- package/dist/node-default-transports.d.ts +27 -0
- package/dist/node-default-transports.js +35 -0
- package/dist/node-default-transports.js.map +1 -0
- package/dist/node-transports.d.ts +30 -0
- package/dist/node-transports.js +40 -0
- package/dist/node-transports.js.map +1 -0
- package/dist/peer-dial.d.ts +124 -27
- package/dist/peer-dial.js +254 -30
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.js +6 -0
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/peer-record.d.ts +6 -45
- package/dist/peer-record.js +8 -50
- package/dist/peer-record.js.map +1 -1
- package/dist/primary-lan-address.d.ts +8 -0
- package/dist/primary-lan-address.js +46 -0
- package/dist/primary-lan-address.js.map +1 -0
- package/dist/provider/client.d.ts +131 -0
- package/dist/provider/client.js +226 -0
- package/dist/provider/client.js.map +1 -0
- package/dist/provider/descriptor.d.ts +41 -0
- package/dist/provider/descriptor.js +103 -0
- package/dist/provider/descriptor.js.map +1 -0
- package/dist/provider/index.d.ts +9 -0
- package/dist/provider/index.js +10 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/provider/oauth.d.ts +125 -0
- package/dist/provider/oauth.js +199 -0
- package/dist/provider/oauth.js.map +1 -0
- package/dist/provider/rent-node.d.ts +101 -0
- package/dist/provider/rent-node.js +220 -0
- package/dist/provider/rent-node.js.map +1 -0
- package/dist/relay-addrs.d.ts +9 -4
- package/dist/relay-addrs.js +9 -4
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +51 -16
- package/dist/relay-reservation.js +107 -24
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +22 -5
- package/dist/seed-bootstrap.js +48 -25
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/strand-commit-durability.d.ts +54 -0
- package/dist/strand-commit-durability.js +74 -0
- package/dist/strand-commit-durability.js.map +1 -0
- package/dist/strand-database.d.ts +23 -8
- package/dist/strand-database.js +41 -13
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +7 -1
- package/dist/strand-first-sync-gate.js +7 -1
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-protocol.js +4 -1
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-fret-addrs.d.ts +19 -1
- package/dist/strand-fret-addrs.js +27 -9
- package/dist/strand-fret-addrs.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +81 -19
- package/dist/strand-instance-manager.js +216 -105
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-move.d.ts +124 -0
- package/dist/strand-move.js +438 -0
- package/dist/strand-move.js.map +1 -0
- package/dist/strand-network-config.js +7 -6
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-peer-redial.d.ts +113 -0
- package/dist/strand-peer-redial.js +236 -0
- package/dist/strand-peer-redial.js.map +1 -0
- package/dist/strand-wake-protocol.js +10 -7
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +212 -4
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -1
- package/package.json +18 -10
- package/src/cadre-invite-protocol.ts +1052 -864
- package/src/cadre-join-request.ts +167 -0
- package/src/cadre-node.ts +10003 -9381
- package/src/control-database.ts +4663 -4451
- package/src/control-schema.ts +19 -3
- package/src/ed25519-key.ts +20 -10
- package/src/fetch-impl.ts +23 -0
- package/src/file-durable-slot.ts +64 -63
- package/src/file-safe-name.ts +32 -0
- package/src/formation-approval.ts +604 -610
- package/src/fs-atomic.ts +92 -117
- package/src/index.ts +71 -4
- package/src/invitation-bootstrap.ts +91 -13
- package/src/joined-strand-store.ts +508 -498
- package/src/key-store-file.ts +142 -141
- package/src/link-budget.ts +9 -4
- package/src/machine-removal.ts +216 -0
- package/src/membership-connection-gater.ts +638 -637
- package/src/node-claim-payload.ts +138 -4
- package/src/node-default-transports.ts +38 -0
- package/src/node-transports.ts +49 -0
- package/src/peer-dial.ts +604 -336
- package/src/peer-join-backfill.ts +6 -0
- package/src/peer-record.ts +159 -202
- package/src/primary-lan-address.ts +47 -0
- package/src/provider/client.ts +326 -0
- package/src/provider/descriptor.ts +129 -0
- package/src/provider/index.ts +59 -0
- package/src/provider/oauth.ts +285 -0
- package/src/provider/rent-node.ts +275 -0
- package/src/relay-addrs.ts +9 -4
- package/src/relay-reservation.ts +1211 -1121
- package/src/seed-bootstrap.ts +1502 -1471
- package/src/strand-commit-durability.ts +116 -0
- package/src/strand-database.ts +46 -14
- package/src/strand-first-sync-gate.ts +7 -1
- package/src/strand-formation-protocol.ts +1039 -1036
- package/src/strand-fret-addrs.ts +179 -145
- package/src/strand-instance-manager.ts +2026 -1883
- package/src/strand-move.ts +585 -0
- package/src/strand-network-config.ts +7 -6
- package/src/strand-peer-redial.ts +307 -0
- package/src/strand-wake-protocol.ts +10 -7
- package/src/types.ts +220 -4
|
@@ -1,1036 +1,1039 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Native cadre-core strand-formation transport.
|
|
3
|
-
*
|
|
4
|
-
* Mirrors the `seed-bootstrap.ts` protocol service: a dedicated
|
|
5
|
-
* libp2p protocol id, length-prefixed JSON frames, and small single-purpose
|
|
6
|
-
* session helpers. It replaces the removed `@serfab/strand-proto` package's transport,
|
|
7
|
-
* carrying the caller's REAL invitation token + disclosure and BOTH parties'
|
|
8
|
-
* real cadre peer addresses end-to-end (no `{ partyId: sessionId }` / `cadre-*.local`
|
|
9
|
-
* placeholders), and validating the responder's result on the initiator side.
|
|
10
|
-
*
|
|
11
|
-
* Provisioning flow (`responderCreates`, 2 messages): the responder provisions (or, for
|
|
12
|
-
* provision-then-record, resolves + records consent against) the strand and returns the
|
|
13
|
-
* result on approval.
|
|
14
|
-
*
|
|
15
|
-
* Cadre-disclosure timing: the responder reveals its own party id + cadre addresses
|
|
16
|
-
* — and, for a closed strand returned via provision-then-record, that strand's
|
|
17
|
-
* membership key — ONLY after the token and disclosure validate; a rejection
|
|
18
|
-
* discloses none of them.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import debug from 'debug';
|
|
22
|
-
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
23
|
-
import type { Libp2p } from '@libp2p/interface';
|
|
24
|
-
import type { StrandFormationDisclosure, StrandMembershipInvite } from './types.js';
|
|
25
|
-
import { type ControlStream, writeFrame, withDeadline, withTimeout } from './control-stream.js';
|
|
26
|
-
import { canonicalJson } from './canonical-json.js';
|
|
27
|
-
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
28
|
-
import { verifyFormationConsent } from './peer-authorization.js';
|
|
29
|
-
// seed-bootstrap imports neither this protocol nor the manager/solicitation layers,
|
|
30
|
-
// so this import introduces no cycle.
|
|
31
|
-
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
32
|
-
import { trailingPeerId } from './peer-
|
|
33
|
-
import {
|
|
34
|
-
formationDeadlines,
|
|
35
|
-
resolveProvisionTimeoutMs,
|
|
36
|
-
responderClampReserveMs,
|
|
37
|
-
splitProvisionBudget
|
|
38
|
-
} from './strand-formation-deadlines.js';
|
|
39
|
-
import {
|
|
40
|
-
FormationRejectedError,
|
|
41
|
-
FormationUnreachableError,
|
|
42
|
-
type FormationRejectionCode
|
|
43
|
-
} from './strand-formation-rejection.js';
|
|
44
|
-
|
|
45
|
-
const log = debug('sereus:cadre:formation-proto');
|
|
46
|
-
|
|
47
|
-
/** Protocol id for the native formation transport (parallel to `/sereus/seed/1.0.0`). */
|
|
48
|
-
export const FORMATION_PROTOCOL = '/sereus/formation/1.0.0';
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Rejection reason for an invitation that cannot be redeemed — unknown here
|
|
52
|
-
* (`'token-unknown'`), or expired or fully spent (`'token-spent'`); the code tells them
|
|
53
|
-
* apart. Shared with `StrandFormationManager`, which reports an `InvitationExhaustedError`
|
|
54
|
-
* (an invite spent out from under a redemption already in flight) with this SAME wording and
|
|
55
|
-
* code on purpose: a joiner that lost the race must be indistinguishable from a non-racing
|
|
56
|
-
* latecomer, so the two sites must never drift apart.
|
|
57
|
-
*/
|
|
58
|
-
export const INVALID_TOKEN_REASON = 'Invalid token';
|
|
59
|
-
|
|
60
|
-
/** Maximum formation message size (1MB). */
|
|
61
|
-
const MAX_FORMATION_MSG_SIZE = 1024 * 1024;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Default cap on concurrent inbound formation sessions.
|
|
65
|
-
*
|
|
66
|
-
* NOTE: a session holds its slot for its whole provisioning budget, 171 s at the default
|
|
67
|
-
* declared link ({@link formationDeadlines}), so a slow hook or commit holds slots for
|
|
68
|
-
* minutes. Only a caller holding a valid token AND a valid consent signature reaches
|
|
69
|
-
* provisioning — a stranger is cut at the contact wait (7 s at the default) or at the first
|
|
70
|
-
* validation read — so the exposure is one invitee opening many sessions with one token. If
|
|
71
|
-
* invitation tokens are ever published where untrusted parties can read them, cap in-flight
|
|
72
|
-
* sessions per token rather than lowering this.
|
|
73
|
-
*/
|
|
74
|
-
const DEFAULT_MAX_CONCURRENT_SESSIONS = 100;
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Cap on the strand-network addresses a formation result carries
|
|
78
|
-
* ({@link FormationResultMessage.strandAddrs}). A node's own strand multiaddrs are a
|
|
79
|
-
* handful of entries (one per listen transport, plus a circuit-relay reservation), so
|
|
80
|
-
* 16 is generous for the honest case while keeping the result frame bounded against a
|
|
81
|
-
* peer that would pad it — either direction, since both sides run the list through
|
|
82
|
-
* {@link sanitizeStrandAddrs}. The initiator's kept copy is one arriving list, replaced by
|
|
83
|
-
* the next formation against the same strand, so the same bound holds there.
|
|
84
|
-
*/
|
|
85
|
-
const MAX_STRAND_ADDRS = 16;
|
|
86
|
-
|
|
87
|
-
// ── Roles ────────────────────────────────────────────────────────────────────
|
|
88
|
-
|
|
89
|
-
export type FormationParty = 'initiator' | 'responder';
|
|
90
|
-
|
|
91
|
-
// ── Wire messages (length-prefixed JSON frames) ──────────────────────────────
|
|
92
|
-
|
|
93
|
-
export interface FormationStrandInfo {
|
|
94
|
-
strandId: string;
|
|
95
|
-
createdBy: FormationParty;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export interface FormationDbConnectionInfo {
|
|
99
|
-
endpoint: string;
|
|
100
|
-
credentialsRef: string;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export interface FormationProvisionResult {
|
|
104
|
-
strand: FormationStrandInfo;
|
|
105
|
-
/**
|
|
106
|
-
* The strand's membership key (closed-strand read-gating secret), delivered to a
|
|
107
|
-
* validated invitee for provision-then-record formation. Disclosed only AFTER token
|
|
108
|
-
* + disclosure validation, exactly like the responder identity/cadre — a rejected or
|
|
109
|
-
* already-used token discloses neither identity nor key. Absent for open strands.
|
|
110
|
-
*/
|
|
111
|
-
memberPrivateKey?: string;
|
|
112
|
-
/**
|
|
113
|
-
* The joiner's single-use membership invitation into the (closed, bound) host
|
|
114
|
-
* strand — issued by the responder's live strand runtime against `Strand.Invite`
|
|
115
|
-
* just before the consent row was recorded. Same disclosure timing as
|
|
116
|
-
* {@link memberPrivateKey}: present only on an approved result, absent for open
|
|
117
|
-
* strands and the responder-provisions path. Bounded and shape-checked on the
|
|
118
|
-
* initiator side ({@link isWellFormedMembershipInvite}) — a hostile responder's
|
|
119
|
-
* malformed field fails validation rather than crashing the initiator.
|
|
120
|
-
*/
|
|
121
|
-
membershipInvite?: StrandMembershipInvite;
|
|
122
|
-
dbConnectionInfo: FormationDbConnectionInfo;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Outcome of the responder's provisioning hook.
|
|
127
|
-
*
|
|
128
|
-
* Distinct from a bare {@link FormationProvisionResult} so the hook can REJECT a
|
|
129
|
-
* formation AFTER token + disclosure validation — e.g. a bound invite naming a host
|
|
130
|
-
* strand this responder has not yet converged on, or a concurrent-redemption collision.
|
|
131
|
-
* Without this channel such cases threw inside provisioning, the stream closed with no
|
|
132
|
-
* result frame, and the initiator saw a read-error/timeout instead of a clean
|
|
133
|
-
* `approved: false`. A rejection still discloses NO responder identity/cadre.
|
|
134
|
-
*/
|
|
135
|
-
export type ResponderProvisionOutcome =
|
|
136
|
-
| { approved: true; result: FormationProvisionResult }
|
|
137
|
-
| FormationRejection;
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* A refusal as the responder sends it: never without a code. Carries nothing else — no
|
|
141
|
-
* identity, addresses or keys — so a rejection discloses nothing about the responder.
|
|
142
|
-
*/
|
|
143
|
-
export interface FormationRejection {
|
|
144
|
-
approved: false;
|
|
145
|
-
code: FormationRejectionCode;
|
|
146
|
-
/** Human-facing text for logs; joiners branch on `code`. */
|
|
147
|
-
reason: string;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** What the responder's token check yields; an invalid token names which way it failed. */
|
|
151
|
-
export type FormationTokenCheck =
|
|
152
|
-
| { valid: true }
|
|
153
|
-
| { valid: false; code: Extract<FormationRejectionCode, 'token-unknown' | 'token-spent'> };
|
|
154
|
-
|
|
155
|
-
/** Initiator → Responder: carries the real token + disclosure + initiator cadre. */
|
|
156
|
-
export interface FormationContactMessage {
|
|
157
|
-
/** The real invitation token. */
|
|
158
|
-
token: string;
|
|
159
|
-
/** Initiator's member key (peer id) — human-facing identity in logs and `FormStrandResult.memberKey`. */
|
|
160
|
-
partyId: string;
|
|
161
|
-
/**
|
|
162
|
-
* Initiator's base64url ed25519 public key — the key behind `partyId`, written to
|
|
163
|
-
* `FormationUsage.PeerKey` and covered by BOTH signed digests.
|
|
164
|
-
*/
|
|
165
|
-
peerKey: string;
|
|
166
|
-
/** Joiner-minted single-use nonce for THIS redemption; both signed digests cover it. */
|
|
167
|
-
usageStampId: string;
|
|
168
|
-
/**
|
|
169
|
-
* The joiner's signature over the `'consent'` digest (see `formationConsentMessage`
|
|
170
|
-
* in control-database.ts) — proof the named peer agreed to this redemption.
|
|
171
|
-
*/
|
|
172
|
-
peerSignature: string;
|
|
173
|
-
/** The real disclosure, carried verbatim. */
|
|
174
|
-
disclosure: StrandFormationDisclosure;
|
|
175
|
-
/** Initiator's real multiaddrs. */
|
|
176
|
-
cadrePeerAddrs: string[];
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Responder → Initiator: responder identity/cadre disclosed only after validation. */
|
|
180
|
-
export interface FormationResultMessage {
|
|
181
|
-
approved: boolean;
|
|
182
|
-
/**
|
|
183
|
-
* Why the responder refused; present iff `approved === false`. Optional on this wire type
|
|
184
|
-
* only because a frame from another version may omit it — the responder always sends one
|
|
185
|
-
* ({@link FormationRejection}), and the joiner reads a missing one as `'unrecognized'`.
|
|
186
|
-
*/
|
|
187
|
-
code?: FormationRejectionCode;
|
|
188
|
-
/** Human-facing text; present iff `approved === false`. */
|
|
189
|
-
reason?: string;
|
|
190
|
-
/** Responder's real party id (disclosed only after validation). */
|
|
191
|
-
partyId?: string;
|
|
192
|
-
/** Responder's real multiaddrs (omitted on rejection). */
|
|
193
|
-
cadrePeerAddrs?: string[];
|
|
194
|
-
/**
|
|
195
|
-
* The responder's live STRAND-network multiaddrs for the provisioned strand — the
|
|
196
|
-
* initiator's only cross-party discovery seed, since the strand-addr RPC that resolves
|
|
197
|
-
* a sibling's strand addrs is membership-gated and answers own-party callers only.
|
|
198
|
-
*
|
|
199
|
-
* Disclosed on the same terms as `partyId`/`cadrePeerAddrs`: only after token +
|
|
200
|
-
* disclosure validation AND an approving provisioning outcome; a rejection carries
|
|
201
|
-
* none. Signaling-first, each entry ending in `/p2p/<responder strand transport
|
|
202
|
-
* peerId>` so the initiator can attribute it. OMITTED when the responder holds no live
|
|
203
|
-
* strand node for the provisioned strand (the responder-provisions path mints a strand
|
|
204
|
-
* that has not launched yet) — an absent or empty list is legal and means "no
|
|
205
|
-
* cross-party seed", never a protocol error.
|
|
206
|
-
*/
|
|
207
|
-
strandAddrs?: string[];
|
|
208
|
-
/** The provisioned strand/db result (always present on approval). */
|
|
209
|
-
provisionResult?: FormationProvisionResult;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** An approving result frame as the responder builds it. */
|
|
213
|
-
type ApprovedResultMessage = FormationResultMessage & { approved: true };
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Normalize a strand-address list arriving from — or heading to — the wire: keep only
|
|
217
|
-
* parsable multiaddr strings, drop duplicates, and cap at {@link MAX_STRAND_ADDRS}.
|
|
218
|
-
* Order is preserved, so the responder's signaling-first ordering survives.
|
|
219
|
-
*
|
|
220
|
-
* Applied on BOTH sides on purpose. The responder bounds what it sends; the initiator
|
|
221
|
-
* bounds what it stores, because a peer is free to ignore the cap. A malformed entry is
|
|
222
|
-
* SKIPPED rather than fatal — these are runtime-discovered addresses (same posture as
|
|
223
|
-
* `extractCircuitRelayTargets` in `delegate-admission.ts`), and one bad entry must not
|
|
224
|
-
* cost a formation that has otherwise succeeded.
|
|
225
|
-
*/
|
|
226
|
-
export function sanitizeStrandAddrs(addrs: unknown): string[] {
|
|
227
|
-
if (!Array.isArray(addrs)) return [];
|
|
228
|
-
const out: string[] = [];
|
|
229
|
-
const seen = new Set<string>();
|
|
230
|
-
for (const entry of addrs) {
|
|
231
|
-
if (out.length >= MAX_STRAND_ADDRS) break;
|
|
232
|
-
if (typeof entry !== 'string' || entry.length === 0) {
|
|
233
|
-
log('strandAddrs: skipping non-string entry %o', entry);
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
if (seen.has(entry)) continue;
|
|
237
|
-
try {
|
|
238
|
-
multiaddr(entry);
|
|
239
|
-
} catch (error) {
|
|
240
|
-
// Deliberately not echoing the raw value at error level: it arrived from a remote
|
|
241
|
-
// peer. The log line below is the observability for a responder announcing junk.
|
|
242
|
-
log('strandAddrs: skipping unparsable entry: %o', error);
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
seen.add(entry);
|
|
246
|
-
out.push(entry);
|
|
247
|
-
}
|
|
248
|
-
return out;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// ── Stream framing helpers ───────────────────────────────────────────────────
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* Reads length-prefixed JSON frames from a libp2p stream one frame at a time.
|
|
255
|
-
*
|
|
256
|
-
* Unlike seed delivery (which reads a stream to EOF then parses once), formation
|
|
257
|
-
* is request/response on a single live stream, so reading must stop at each frame
|
|
258
|
-
* boundary. The reader buffers across chunk boundaries and retains any overflow
|
|
259
|
-
* (a following frame coalesced into the same chunk) for the next read.
|
|
260
|
-
*/
|
|
261
|
-
class FrameReader {
|
|
262
|
-
private readonly iterator: AsyncIterator<Uint8Array>;
|
|
263
|
-
private readonly maxLength: number;
|
|
264
|
-
private buffer = new Uint8Array(0);
|
|
265
|
-
|
|
266
|
-
constructor(stream: AsyncIterable<Uint8Array>, maxLength = MAX_FORMATION_MSG_SIZE) {
|
|
267
|
-
this.iterator = stream[Symbol.asyncIterator]();
|
|
268
|
-
this.maxLength = maxLength;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
private append(chunk: Uint8Array): void {
|
|
272
|
-
const next = new Uint8Array(this.buffer.length + chunk.length);
|
|
273
|
-
next.set(this.buffer, 0);
|
|
274
|
-
next.set(chunk, this.buffer.length);
|
|
275
|
-
this.buffer = next;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
/** Pull chunks until the buffer holds ≥ `need` bytes; false if the stream ended first. */
|
|
279
|
-
private async fill(need: number): Promise<boolean> {
|
|
280
|
-
while (this.buffer.length < need) {
|
|
281
|
-
const { value, done } = await this.iterator.next();
|
|
282
|
-
if (done) return false;
|
|
283
|
-
const bytes = value instanceof Uint8Array
|
|
284
|
-
? value
|
|
285
|
-
: (value as { subarray(): Uint8Array }).subarray();
|
|
286
|
-
this.append(bytes);
|
|
287
|
-
}
|
|
288
|
-
return true;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async read<T>(): Promise<T> {
|
|
292
|
-
if (!(await this.fill(4))) {
|
|
293
|
-
throw new Error('Formation stream closed before length prefix');
|
|
294
|
-
}
|
|
295
|
-
const length = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength).getUint32(0, false);
|
|
296
|
-
if (length > this.maxLength) {
|
|
297
|
-
throw new Error(`Formation frame declares length ${length} exceeding max ${this.maxLength}`);
|
|
298
|
-
}
|
|
299
|
-
if (!(await this.fill(4 + length))) {
|
|
300
|
-
throw new Error(`Formation frame declares length ${length} but stream ended early`);
|
|
301
|
-
}
|
|
302
|
-
const json = new TextDecoder().decode(this.buffer.subarray(4, 4 + length));
|
|
303
|
-
this.buffer = this.buffer.subarray(4 + length);
|
|
304
|
-
return JSON.parse(json) as T;
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// ── Result validation ────────────────────────────────────────────────────────
|
|
309
|
-
|
|
310
|
-
/** Matches the placeholder cadre addrs (`cadre-a-1.local`, …) of the removed strand-proto transport. */
|
|
311
|
-
const PLACEHOLDER_CADRE_ADDR = /^cadre-[ab]-\d+\.local$/;
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Both halves of a {@link StrandMembershipInvite} are base64url ed25519 key encodings —
|
|
315
|
-
* 43 chars for 32 bytes. The bound is deliberately loose (a future encoding change must
|
|
316
|
-
* not silently strand joiners) while still capping what a hostile responder can make the
|
|
317
|
-
* initiator retain.
|
|
318
|
-
*/
|
|
319
|
-
const MAX_MEMBERSHIP_INVITE_KEY_LENGTH = 256;
|
|
320
|
-
const BASE64URL_KEY = /^[A-Za-z0-9_-]+$/;
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Is `value` a well-formed {@link StrandMembershipInvite} — exactly two bounded
|
|
324
|
-
* base64url strings? The initiator-side shape check for the one attacker-influenced
|
|
325
|
-
* OBJECT the formation result carries (`strandAddrs` gets the equivalent treatment in
|
|
326
|
-
* {@link sanitizeStrandAddrs}): a malformed field from a hostile responder must fail
|
|
327
|
-
* validation, never crash the initiator or be carried into the joiner's invite cache.
|
|
328
|
-
* `undefined` is NOT well-formed — absence is legal on the result and callers gate on
|
|
329
|
-
* presence first.
|
|
330
|
-
*/
|
|
331
|
-
export function isWellFormedMembershipInvite(value: unknown): value is StrandMembershipInvite {
|
|
332
|
-
if (typeof value !== 'object' || value === null) return false;
|
|
333
|
-
const { inviteKey, invitePrivateKey } = value as Record<string, unknown>;
|
|
334
|
-
const wellFormedKey = (key: unknown): boolean =>
|
|
335
|
-
typeof key === 'string' && key.length > 0 && key.length <= MAX_MEMBERSHIP_INVITE_KEY_LENGTH && BASE64URL_KEY.test(key);
|
|
336
|
-
return wellFormedKey(inviteKey) && wellFormedKey(invitePrivateKey);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Structural check on a `responderCreates` result: the responder must have approved,
|
|
341
|
-
* disclosed a non-empty real identity + cadre (not the legacy `cadre-*.local`
|
|
342
|
-
* placeholders), and returned a strand vouched for by the responder party with a real id.
|
|
343
|
-
*
|
|
344
|
-
* `createdBy: 'responder'` means "the responder party vouches for / returns this strand."
|
|
345
|
-
* Under provision-then-record that strand was minted owner-signed earlier (not created
|
|
346
|
-
* in-session), but it is still the responder party returning its own strand, so the marker
|
|
347
|
-
* — and this structural validator — stay unchanged.
|
|
348
|
-
*
|
|
349
|
-
* The behavioral floor for the initiator: a responder that returns an arbitrary/empty
|
|
350
|
-
* `strandId` or omits its disclosed identity is rejected, not silently accepted.
|
|
351
|
-
*/
|
|
352
|
-
export function isValidResponderCreatesResult(response: FormationResultMessage): boolean {
|
|
353
|
-
if (!response.approved) return false;
|
|
354
|
-
if (!response.partyId) return false;
|
|
355
|
-
const addrs = response.cadrePeerAddrs;
|
|
356
|
-
if (!addrs || addrs.length === 0) return false;
|
|
357
|
-
if (addrs.some((a) => PLACEHOLDER_CADRE_ADDR.test(a))) return false;
|
|
358
|
-
const provision = response.provisionResult;
|
|
359
|
-
if (!provision?.strand?.strandId) return false;
|
|
360
|
-
if (provision.strand.createdBy !== 'responder') return false;
|
|
361
|
-
// Optional, but when present it must be well-formed: a malformed invitation is a
|
|
362
|
-
// membership the joiner can never redeem, so reject the result rather than accept a
|
|
363
|
-
// formation that would leave a half-member.
|
|
364
|
-
if (provision.membershipInvite !== undefined && !isWellFormedMembershipInvite(provision.membershipInvite)) {
|
|
365
|
-
return false;
|
|
366
|
-
}
|
|
367
|
-
return true;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// ── Responder (listener) ─────────────────────────────────────────────────────
|
|
371
|
-
|
|
372
|
-
export interface FormationListenerOptions {
|
|
373
|
-
/** Validate the invitation token; an invalid one says whether it is unknown here or spent. */
|
|
374
|
-
validateToken(token: string): Promise<FormationTokenCheck>;
|
|
375
|
-
/** Validate the initiator's disclosure with the REAL token + disclosure. */
|
|
376
|
-
validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
|
|
377
|
-
/**
|
|
378
|
-
* Provision (or, for provision-then-record, resolve + record consent against) the
|
|
379
|
-
* strand (`responderCreates`) for the given initiator. The WHOLE contact message is
|
|
380
|
-
* threaded in: the hook needs the REAL token to map the bound host strand, and the
|
|
381
|
-
* joiner's `peerKey`/`usageStampId`/`peerSignature` to write the consent columns.
|
|
382
|
-
*
|
|
383
|
-
* Returns a {@link ResponderProvisionOutcome}: the hook may REJECT post-validation
|
|
384
|
-
* (e.g. an unconverged host strand) and the listener turns that into a clean,
|
|
385
|
-
* non-disclosing `approved: false` reply rather than dropping the result frame.
|
|
386
|
-
*
|
|
387
|
-
* `signal` is aborted when the listener's work budget expires: a hook that observes it
|
|
388
|
-
* BEFORE writing abandons the redemption and leaves the invite unspent. Optional so
|
|
389
|
-
* signal-unaware hooks (tests, mocks) stay assignable.
|
|
390
|
-
*/
|
|
391
|
-
provisionStrand(contact: FormationContactMessage, signal?: AbortSignal): Promise<ResponderProvisionOutcome>;
|
|
392
|
-
/** Responder identity, disclosed only AFTER token + disclosure validation passes. */
|
|
393
|
-
getResponderIdentity(): { partyId: string; cadrePeerAddrs: string[] };
|
|
394
|
-
/**
|
|
395
|
-
* This node's live STRAND-network multiaddrs for a provisioned strand, read on the
|
|
396
|
-
* approval path only (same disclosure timing as {@link getResponderIdentity}) and
|
|
397
|
-
* carried back as {@link FormationResultMessage.strandAddrs}.
|
|
398
|
-
*
|
|
399
|
-
* Optional, and an empty answer is legal: the strand may not be running here yet (the
|
|
400
|
-
* responder-provisions path mints it during this very session). The listener cannot
|
|
401
|
-
* reach the strand runtime itself, so this is the seam `CadreNode` wires to its own
|
|
402
|
-
* per-strand address lookup.
|
|
403
|
-
*/
|
|
404
|
-
resolveStrandAddrs?(strandId: string): string[];
|
|
405
|
-
/**
|
|
406
|
-
* This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
|
|
407
|
-
* every deadline below takes its default ({@link formationDeadlines}). The default
|
|
408
|
-
* declaration when omitted.
|
|
409
|
-
*/
|
|
410
|
-
linkRoundTripMs?: number;
|
|
411
|
-
/** Whole-session budget; default `sessionMs` of the derived ladder. */
|
|
412
|
-
sessionTimeoutMs?: number;
|
|
413
|
-
/** Budget for the contact-frame read; default `awaitContactMs` of the derived ladder. */
|
|
414
|
-
stepTimeoutMs?: number;
|
|
415
|
-
/**
|
|
416
|
-
* Budget for the `provisionStrand` hook call, work plus settle grace — distinct from
|
|
417
|
-
* `stepTimeoutMs` because provisioning is real work, not a bare wire read. Default
|
|
418
|
-
* `provisionWorkMs + provisionGraceMs` of the derived ladder (171 s at the default
|
|
419
|
-
* declaration); `0`/unset uses the default; a value that would outlive the session is
|
|
420
|
-
* clamped ({@link resolveProvisionTimeoutMs}). See {@link formationDeadlines} for the ordering.
|
|
421
|
-
*/
|
|
422
|
-
provisionTimeoutMs?: number;
|
|
423
|
-
maxConcurrentSessions?: number;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Responder side of the native formation protocol. Registers a libp2p handler and
|
|
428
|
-
* drives each inbound stream through token + disclosure validation, provisioning,
|
|
429
|
-
* and result delivery — enforcing the cadre-disclosure timing rule (responder cadre
|
|
430
|
-
* is revealed only after validation; rejections disclose nothing).
|
|
431
|
-
*/
|
|
432
|
-
export class FormationListener {
|
|
433
|
-
private readonly options: FormationListenerOptions;
|
|
434
|
-
private readonly sessionTimeoutMs: number;
|
|
435
|
-
private readonly awaitContactMs: number;
|
|
436
|
-
private readonly provisionWorkMs: number;
|
|
437
|
-
private readonly provisionGraceMs: number;
|
|
438
|
-
private readonly maxConcurrentSessions: number;
|
|
439
|
-
private readonly registered = new Set<Libp2p>();
|
|
440
|
-
private activeSessions = 0;
|
|
441
|
-
private sessionCounter = 0;
|
|
442
|
-
|
|
443
|
-
constructor(options: FormationListenerOptions) {
|
|
444
|
-
this.options = options;
|
|
445
|
-
const deadlines = formationDeadlines(options.linkRoundTripMs);
|
|
446
|
-
this.sessionTimeoutMs = options.sessionTimeoutMs ?? deadlines.sessionMs;
|
|
447
|
-
this.awaitContactMs = options.stepTimeoutMs ?? deadlines.awaitContactMs;
|
|
448
|
-
const split = splitProvisionBudget(resolveProvisionTimeoutMs(
|
|
449
|
-
options.provisionTimeoutMs, deadlines.provisionWorkMs + deadlines.provisionGraceMs,
|
|
450
|
-
this.sessionTimeoutMs, this.awaitContactMs + deadlines.validationMs, 'FormationListener',
|
|
451
|
-
responderClampReserveMs(deadlines, this.awaitContactMs)
|
|
452
|
-
), deadlines.provisionGraceMs);
|
|
453
|
-
this.provisionWorkMs = split.workMs;
|
|
454
|
-
this.provisionGraceMs = split.graceMs;
|
|
455
|
-
this.maxConcurrentSessions = options.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/** Number of in-flight inbound sessions. */
|
|
459
|
-
get activeCount(): number {
|
|
460
|
-
return this.activeSessions;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
/** Register the formation handler on `node`. Rejects when libp2p refuses the registration. */
|
|
464
|
-
async register(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): Promise<void> {
|
|
465
|
-
if (this.registered.has(node)) {
|
|
466
|
-
log('node already registered');
|
|
467
|
-
return;
|
|
468
|
-
}
|
|
469
|
-
await node.handle(protocolId, async (rawStream: unknown, _connection: unknown) => {
|
|
470
|
-
await this.handleStream(rawStream as ControlStream);
|
|
471
|
-
});
|
|
472
|
-
this.registered.add(node);
|
|
473
|
-
log('formation listener registered (%s)', protocolId);
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
async unregister(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): Promise<void> {
|
|
477
|
-
if (!this.registered.has(node)) return;
|
|
478
|
-
await node.unhandle(protocolId);
|
|
479
|
-
this.registered.delete(node);
|
|
480
|
-
log('formation listener unregistered (%s)', protocolId);
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
private async handleStream(stream: ControlStream): Promise<void> {
|
|
484
|
-
if (this.activeSessions >= this.maxConcurrentSessions) {
|
|
485
|
-
const rejection: FormationRejection = { approved: false, code: 'busy', reason: 'Too many concurrent formation sessions' };
|
|
486
|
-
try { writeFrame(stream, rejection); } catch { /* best effort */ }
|
|
487
|
-
try { await stream.close(); } catch { /* ignore */ }
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
const id = ++this.sessionCounter;
|
|
491
|
-
this.activeSessions++;
|
|
492
|
-
try {
|
|
493
|
-
await withTimeout(this.sessionTimeoutMs, `Formation session#${id}`, () => this.runSession(id, stream));
|
|
494
|
-
} catch (err) {
|
|
495
|
-
log('formation session #%d failed: %o', id, err);
|
|
496
|
-
} finally {
|
|
497
|
-
this.activeSessions--;
|
|
498
|
-
try { await stream.close(); } catch { /* ignore */ }
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
/**
|
|
503
|
-
* Run the provisioning hook under its WORK budget — the provisioning budget minus the
|
|
504
|
-
* trailing settle grace ({@link splitProvisionBudget}).
|
|
505
|
-
*
|
|
506
|
-
* When the work budget expires the hook's signal is aborted, then
|
|
507
|
-
* {@link settleWithinGrace} waits out the grace for the aborted work to settle anyway.
|
|
508
|
-
* Returns `undefined` for "timed out" — which the caller turns into a retryable rejection
|
|
509
|
-
* frame — and rethrows every other failure so it reaches the internal-error path.
|
|
510
|
-
*
|
|
511
|
-
* Deliberately not `withDeadline`: that exposes neither the timed-out flag nor the signal
|
|
512
|
-
* outside its `op`, and both are needed here.
|
|
513
|
-
*/
|
|
514
|
-
private async provision(id: number, contact: FormationContactMessage): Promise<ResponderProvisionOutcome | undefined> {
|
|
515
|
-
const controller = new AbortController();
|
|
516
|
-
let timedOut = false;
|
|
517
|
-
const pending = this.options.provisionStrand(contact, controller.signal);
|
|
518
|
-
try {
|
|
519
|
-
return await withTimeout(
|
|
520
|
-
this.provisionWorkMs,
|
|
521
|
-
`Formation provisioning#${id}`,
|
|
522
|
-
() => pending,
|
|
523
|
-
() => { timedOut = true; controller.abort(); }
|
|
524
|
-
);
|
|
525
|
-
} catch (err) {
|
|
526
|
-
if (!timedOut) throw err;
|
|
527
|
-
log(
|
|
528
|
-
'formation session #%d provisioning work budget expired after %dms; aborted, settling up to %dms',
|
|
529
|
-
id, this.provisionWorkMs, this.provisionGraceMs
|
|
530
|
-
);
|
|
531
|
-
return await this.settleWithinGrace(id, pending);
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
/**
|
|
536
|
-
* Wait out the settle grace for an ABORTED provisioning, adopting a late success.
|
|
537
|
-
*
|
|
538
|
-
* A hook that observed the abort before issuing its `FormationUsage` insert rejects (with
|
|
539
|
-
* `FormationAbortedError`) and leaves the invite unspent → reported as a timeout. A hook
|
|
540
|
-
* that had already written cannot be un-written (the table is append-only), so if it lands
|
|
541
|
-
* inside the grace its outcome is ADOPTED — the joiner is told the truth (approved, or the
|
|
542
|
-
* hook's own rejection reason) rather than "timed out" over a spent invite.
|
|
543
|
-
*
|
|
544
|
-
* The grace is derived to contain one seat read plus one commit at the declared link
|
|
545
|
-
* ({@link formationDeadlines}), which is exactly what runs after the insert attempt passes
|
|
546
|
-
* its abort check, so at that link the late outcome lands inside it.
|
|
547
|
-
*
|
|
548
|
-
* NOTE: a commit that outlasts even the derived grace (a link slower than declared, a
|
|
549
|
-
* configured `provisionTimeoutMs` whose half-cap shrank the grace, or a commit phase that
|
|
550
|
-
* re-picks an unanswering coordinator, which Optimystic lets run to its transaction budget,
|
|
551
|
-
* 170 s at the default declared link) still tells the joiner
|
|
552
|
-
* 'timed out' while its one-time invite is in fact spent, and since every retry mints a
|
|
553
|
-
* fresh keypair no recovery path can match it. Nothing can un-spend an append-only row, so
|
|
554
|
-
* the late-settle logging below is the observability for it; if it is seen in the wild,
|
|
555
|
-
* raise the declared link rather than this grace.
|
|
556
|
-
*/
|
|
557
|
-
private async settleWithinGrace(
|
|
558
|
-
id: number,
|
|
559
|
-
pending: Promise<ResponderProvisionOutcome>
|
|
560
|
-
): Promise<ResponderProvisionOutcome | undefined> {
|
|
561
|
-
let stillPending = false;
|
|
562
|
-
try {
|
|
563
|
-
const outcome = await withTimeout(
|
|
564
|
-
this.provisionGraceMs,
|
|
565
|
-
`Formation settle#${id}`,
|
|
566
|
-
() => pending,
|
|
567
|
-
() => { stillPending = true; }
|
|
568
|
-
);
|
|
569
|
-
log('formation session #%d provisioning settled within its grace: approved=%s — adopting', id, outcome.approved);
|
|
570
|
-
return outcome;
|
|
571
|
-
} catch (err) {
|
|
572
|
-
if (!stillPending) {
|
|
573
|
-
log('formation session #%d provisioning failed after its abort: %o', id, err);
|
|
574
|
-
return undefined;
|
|
575
|
-
}
|
|
576
|
-
log('formation session #%d provisioning still pending after its %dms grace', id, this.provisionGraceMs);
|
|
577
|
-
// Log how it eventually settled so a late failure is not swallowed by the two
|
|
578
|
-
// abandoned `withTimeout` calls.
|
|
579
|
-
void pending.then(
|
|
580
|
-
(late) => log('formation session #%d provisioning settled after its grace: approved=%s', id, late.approved),
|
|
581
|
-
(err2) => log('formation session #%d provisioning failed after its grace: %o', id, err2)
|
|
582
|
-
);
|
|
583
|
-
return undefined;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/**
|
|
588
|
-
* Joiner-consent pre-check — the legibility twin of the schema's
|
|
589
|
-
* `FormationUsage.PeerConsented` CHECK, exactly as `verifyFormationApproval` is for the
|
|
590
|
-
* approver's vouch. The database constraint stays the authority; this turns a bad consent
|
|
591
|
-
* into a clean 'Invalid joiner consent' rejection instead of an opaque CHECK failure at
|
|
592
|
-
* commit. Runs BEFORE token/disclosure validation: it is the cheapest check (pure local
|
|
593
|
-
* crypto, no DB read) and a rejection discloses nothing.
|
|
594
|
-
*
|
|
595
|
-
* Also pins `partyId` to `peerKey`: an ed25519 peer id is an identity multihash of the
|
|
596
|
-
* very key, and this is the ONLY layer that can check the match — SQL cannot unwrap a
|
|
597
|
-
* multihash, and `partyId` itself is never stored.
|
|
598
|
-
*/
|
|
599
|
-
private isJoinerConsentValid(id: number, contact: FormationContactMessage): boolean {
|
|
600
|
-
try {
|
|
601
|
-
requireEd25519PublicKeyB64(contact.peerKey, 'joiner peer key');
|
|
602
|
-
} catch {
|
|
603
|
-
// Deliberately not rethrown or echoed: the helper's error message embeds the whole
|
|
604
|
-
// rejected value, and this one arrived from a remote peer (the cap-the-echo case its
|
|
605
|
-
// own NOTE warns about). Log without the raw value instead.
|
|
606
|
-
log('formation session #%d: joiner peer key is not a base64url ed25519 public key', id);
|
|
607
|
-
return false;
|
|
608
|
-
}
|
|
609
|
-
const embedded = ed25519PublicKeyB64FromPeerId(contact.partyId);
|
|
610
|
-
if (!embedded || embedded !== contact.peerKey) {
|
|
611
|
-
log('formation session #%d: partyId does not embed the presented peer key', id);
|
|
612
|
-
return false;
|
|
613
|
-
}
|
|
614
|
-
const consentOk = verifyFormationConsent({
|
|
615
|
-
token: contact.token,
|
|
616
|
-
usageStampId: contact.usageStampId,
|
|
617
|
-
peerKey: contact.peerKey,
|
|
618
|
-
disclosure: canonicalJson(contact.disclosure),
|
|
619
|
-
peerSig: contact.peerSignature
|
|
620
|
-
});
|
|
621
|
-
if (!consentOk) {
|
|
622
|
-
log('formation session #%d: joiner consent signature failed verification', id);
|
|
623
|
-
return false;
|
|
624
|
-
}
|
|
625
|
-
return true;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/**
|
|
629
|
-
* The strand addresses to disclose for an APPROVED provisioning, sanitized and capped.
|
|
630
|
-
*
|
|
631
|
-
* Never throws: a hook that fails (a strand runtime torn down mid-session) costs the
|
|
632
|
-
* joiner its cross-party seed, not the formation it has already earned — the seed is
|
|
633
|
-
* an optimization, the consent row is the commitment.
|
|
634
|
-
*/
|
|
635
|
-
private strandAddrsFor(id: number, strandId: string): string[] {
|
|
636
|
-
if (!this.options.resolveStrandAddrs) return [];
|
|
637
|
-
try {
|
|
638
|
-
return sanitizeStrandAddrs(this.options.resolveStrandAddrs(strandId));
|
|
639
|
-
} catch (err) {
|
|
640
|
-
log('formation session #%d: strand-addr lookup for %s failed (continuing): %o', id, strandId, err);
|
|
641
|
-
return [];
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
private async runSession(id: number, stream: ControlStream): Promise<void> {
|
|
646
|
-
// Track whether ANY frame has been written so the catch below can convert an
|
|
647
|
-
// unexpected internal error into a non-disclosing rejection ONLY when nothing has
|
|
648
|
-
// gone out yet. This closes the "stream closed with no result frame" class of bug.
|
|
649
|
-
let wroteFrame = false;
|
|
650
|
-
const send = (msg: FormationRejection | ApprovedResultMessage): void => {
|
|
651
|
-
writeFrame(stream, msg);
|
|
652
|
-
wroteFrame = true;
|
|
653
|
-
};
|
|
654
|
-
|
|
655
|
-
try {
|
|
656
|
-
const reader = new FrameReader(stream);
|
|
657
|
-
const contact = await withTimeout(this.awaitContactMs, `Formation await-contact#${id}`, () => reader.read<FormationContactMessage>());
|
|
658
|
-
log('formation session #%d contact: token=%s party=%s', id, contact.token, contact.partyId);
|
|
659
|
-
|
|
660
|
-
if (!this.isJoinerConsentValid(id, contact)) {
|
|
661
|
-
send({ approved: false, code: 'consent-invalid', reason: 'Invalid joiner consent' });
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
const tokenResult = await this.options.validateToken(contact.token);
|
|
666
|
-
if (!tokenResult.valid) {
|
|
667
|
-
send({ approved: false, code: tokenResult.code, reason: INVALID_TOKEN_REASON });
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
const disclosureOk = await this.options.validateDisclosure(contact.token, contact.disclosure);
|
|
672
|
-
if (!disclosureOk) {
|
|
673
|
-
send({ approved: false, code: 'disclosure-invalid', reason: 'Invalid disclosure' });
|
|
674
|
-
return;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const outcome = await this.provision(id, contact);
|
|
678
|
-
if (!outcome) {
|
|
679
|
-
// Reported as its own retryable reason rather than falling through to the generic
|
|
680
|
-
// catch below, which would report the misleading 'Internal formation error'.
|
|
681
|
-
send({ approved: false, code: 'provisioning-timeout', reason: 'Formation provisioning timed out' });
|
|
682
|
-
return;
|
|
683
|
-
}
|
|
684
|
-
if (!outcome.approved) {
|
|
685
|
-
// A post-validation rejection still discloses NEITHER identity NOR cadre, exactly
|
|
686
|
-
// like the token/disclosure rejections above — copied field by field so nothing else
|
|
687
|
-
// a hook put on its outcome reaches the wire.
|
|
688
|
-
send({ approved: false, code: outcome.code, reason: outcome.reason });
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
// Validation + provisioning passed → safe to disclose responder identity/cadre.
|
|
692
|
-
const identity = this.options.getResponderIdentity();
|
|
693
|
-
const strandAddrs = this.strandAddrsFor(id, outcome.result.strand.strandId);
|
|
694
|
-
send({
|
|
695
|
-
approved: true,
|
|
696
|
-
partyId: identity.partyId,
|
|
697
|
-
cadrePeerAddrs: identity.cadrePeerAddrs,
|
|
698
|
-
// Omitted rather than sent empty: an absent field and an empty list mean the same
|
|
699
|
-
// thing to the initiator, and omitting keeps every rejection-parity assertion a
|
|
700
|
-
// plain `toBeUndefined()`.
|
|
701
|
-
...(strandAddrs.length > 0 ? { strandAddrs } : {}),
|
|
702
|
-
provisionResult: outcome.result
|
|
703
|
-
});
|
|
704
|
-
} catch (err) {
|
|
705
|
-
// Defense-in-depth: if an unexpected internal error escapes BEFORE any frame was
|
|
706
|
-
// written (e.g. a future provisionStrand-hook bug), convert it into a non-disclosing
|
|
707
|
-
// protocol rejection so the initiator sees a clean error instead of a read-error/
|
|
708
|
-
// timeout. If a frame was already sent, leave the stream as-is and let handleStream
|
|
709
|
-
// log + close. Re-throw either way so the failure is still recorded — this is a
|
|
710
|
-
// deliberate, logged conversion, not silent.
|
|
711
|
-
if (!wroteFrame) {
|
|
712
|
-
const internalError: FormationRejection = { approved: false, code: 'internal', reason: 'Internal formation error' };
|
|
713
|
-
try { writeFrame(stream, internalError); } catch { /* stream already broken */ }
|
|
714
|
-
}
|
|
715
|
-
throw err;
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// ── Initiator (dialer) ───────────────────────────────────────────────────────
|
|
721
|
-
|
|
722
|
-
export interface FormationDialOptions {
|
|
723
|
-
/** The contact message to send (real token, partyId, disclosure, initiator cadre). */
|
|
724
|
-
contact: FormationContactMessage;
|
|
725
|
-
/**
|
|
726
|
-
* Responder multiaddrs to dial, normally the invitation's bootstrap list. Every entry that
|
|
727
|
-
* parses is tried, in the order libp2p's default address sorter picks (loopback last, then
|
|
728
|
-
* public before private, then circuit after direct), until one connects, all under one
|
|
729
|
-
* dial-connect budget (`dialTimeoutMs`); an entry that does not parse is skipped.
|
|
730
|
-
*
|
|
731
|
-
* {@link dialFormation} dials ONE machine, so every entry must name the same trailing peer
|
|
732
|
-
* id (or none). An invitation names several machines of the inviting party; hand it to
|
|
733
|
-
* {@link dialFormationByMachine}, which splits the list by trailing peer id, drops entries
|
|
734
|
-
* that name no peer, and runs one {@link dialFormation} session per machine, in the order
|
|
735
|
-
* each machine first appears. Each session keeps its own deadline ladder, so a machine that
|
|
736
|
-
* cannot be dialled costs one `dialTimeoutMs` and one that accepts the contact and then
|
|
737
|
-
* stalls costs up to one `sessionTimeoutMs`; `CadreNode.createOpenInvitation` names at most
|
|
738
|
-
* four machines.
|
|
739
|
-
*/
|
|
740
|
-
responderAddrs: string[];
|
|
741
|
-
/** Validate the responder's result; a false return aborts the formation. */
|
|
742
|
-
validateResponse(response: FormationResultMessage): Promise<boolean>;
|
|
743
|
-
/**
|
|
744
|
-
* This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
|
|
745
|
-
* every deadline below takes its default ({@link formationDeadlines}). The default
|
|
746
|
-
* declaration when omitted.
|
|
747
|
-
*/
|
|
748
|
-
linkRoundTripMs?: number;
|
|
749
|
-
/** Whole-session budget; default `sessionMs` of the derived ladder. */
|
|
750
|
-
sessionTimeoutMs?: number;
|
|
751
|
-
/**
|
|
752
|
-
* Budget for `dial-connect`: opening the connection, through a relay if need be, and
|
|
753
|
-
* negotiating the formation protocol. Default `dialMs` of the derived ladder.
|
|
754
|
-
*/
|
|
755
|
-
dialTimeoutMs?: number;
|
|
756
|
-
/**
|
|
757
|
-
* Budget for the `await-response` read only (the responder's provisioning plus the reply's
|
|
758
|
-
* travel time). Default `initiatorAwaitResponseMs` of the derived ladder; `0`/unset uses
|
|
759
|
-
* the default; a value that would outlive the session is clamped. See
|
|
760
|
-
* {@link formationDeadlines} for the ordering.
|
|
761
|
-
*/
|
|
762
|
-
provisionTimeoutMs?: number;
|
|
763
|
-
protocolId?: string;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* What one successful formation dial yields the initiator.
|
|
768
|
-
*
|
|
769
|
-
* The provisioned strand travels in {@link FormationProvisionResult}, but the responder's
|
|
770
|
-
* strand-network addresses are NOT part of that structure — they are a disclosure of the
|
|
771
|
-
* responder's live runtime, alongside `partyId`/`cadrePeerAddrs`, not a property of the
|
|
772
|
-
* strand row. Returning them beside the provision result keeps that separation while
|
|
773
|
-
* still handing the caller the one thing it needs to seed a cross-party mesh.
|
|
774
|
-
*
|
|
775
|
-
* `strandAddrs` is always an array and is frequently EMPTY — see
|
|
776
|
-
* {@link FormationResultMessage.strandAddrs}.
|
|
777
|
-
*/
|
|
778
|
-
export interface FormationDialResult {
|
|
779
|
-
provision: FormationProvisionResult;
|
|
780
|
-
/** Sanitized + capped ({@link sanitizeStrandAddrs}); `[]` when the responder sent none. */
|
|
781
|
-
strandAddrs: string[];
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
/**
|
|
785
|
-
* Open the formation stream under the dial deadline, cancelling the dial when it expires.
|
|
786
|
-
*
|
|
787
|
-
* The deadline's signal goes into `dialProtocol`, so an expired dial is abandoned by libp2p
|
|
788
|
-
* rather than left running. A dial that settles in the same tick the deadline fires loses
|
|
789
|
-
* the race to the timeout rejection and would leave its stream open with nobody to close
|
|
790
|
-
* it, so a stream that arrives after the signal aborted is reset here — the same treatment
|
|
791
|
-
* `exchangeFrame` (`control-stream.ts`) gives a stream dialed past its deadline. A late
|
|
792
|
-
* rejection is the abort itself, or reaches the caller through the awaited copy, so the
|
|
793
|
-
* handler below only stops it being reported as unhandled.
|
|
794
|
-
*
|
|
795
|
-
* NOTE: libp2p tries `addrs` one after another, and they all share this one dial budget. Its
|
|
796
|
-
* per-address limit (`addressDialTimeout`: ten link round trips plus two admission decisions, as
|
|
797
|
-
* `optimysticDialLimits` states it) is longer than `dialTimeoutMs` (`formationDeadlines().dialMs`:
|
|
798
|
-
* five round trips plus one decision) at every declaration, so it never cuts off an address
|
|
799
|
-
* inside this dial. An address that hangs without answering (a black-holed relay host) uses the
|
|
800
|
-
* whole budget, and the join moves on to the party's next machine
|
|
801
|
-
* ({@link dialFormationByMachine}) rather than to this machine's next address; an address that is
|
|
802
|
-
* refused fails fast and still hands over to the next. A budget per address here would overrun
|
|
803
|
-
* the session or shrink the await-response budget ({@link formationDeadlines}). If joins through a
|
|
804
|
-
* hung first address are seen in practice, give each address a sub-budget or dial them in parallel.
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
*
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
return
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
*
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
*
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
*
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
if (!
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
const
|
|
984
|
-
const
|
|
985
|
-
const
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
*
|
|
1009
|
-
*
|
|
1010
|
-
*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
*
|
|
1016
|
-
*
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Native cadre-core strand-formation transport.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the `seed-bootstrap.ts` protocol service: a dedicated
|
|
5
|
+
* libp2p protocol id, length-prefixed JSON frames, and small single-purpose
|
|
6
|
+
* session helpers. It replaces the removed `@serfab/strand-proto` package's transport,
|
|
7
|
+
* carrying the caller's REAL invitation token + disclosure and BOTH parties'
|
|
8
|
+
* real cadre peer addresses end-to-end (no `{ partyId: sessionId }` / `cadre-*.local`
|
|
9
|
+
* placeholders), and validating the responder's result on the initiator side.
|
|
10
|
+
*
|
|
11
|
+
* Provisioning flow (`responderCreates`, 2 messages): the responder provisions (or, for
|
|
12
|
+
* provision-then-record, resolves + records consent against) the strand and returns the
|
|
13
|
+
* result on approval.
|
|
14
|
+
*
|
|
15
|
+
* Cadre-disclosure timing: the responder reveals its own party id + cadre addresses
|
|
16
|
+
* — and, for a closed strand returned via provision-then-record, that strand's
|
|
17
|
+
* membership key — ONLY after the token and disclosure validate; a rejection
|
|
18
|
+
* discloses none of them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import debug from 'debug';
|
|
22
|
+
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
23
|
+
import type { Libp2p } from '@libp2p/interface';
|
|
24
|
+
import type { StrandFormationDisclosure, StrandMembershipInvite } from './types.js';
|
|
25
|
+
import { type ControlStream, writeFrame, withDeadline, withTimeout } from './control-stream.js';
|
|
26
|
+
import { canonicalJson } from './canonical-json.js';
|
|
27
|
+
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
28
|
+
import { verifyFormationConsent } from './peer-authorization.js';
|
|
29
|
+
// seed-bootstrap imports neither this protocol nor the manager/solicitation layers,
|
|
30
|
+
// so this import introduces no cycle.
|
|
31
|
+
import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
|
|
32
|
+
import { trailingPeerId } from './peer-dial.js';
|
|
33
|
+
import {
|
|
34
|
+
formationDeadlines,
|
|
35
|
+
resolveProvisionTimeoutMs,
|
|
36
|
+
responderClampReserveMs,
|
|
37
|
+
splitProvisionBudget
|
|
38
|
+
} from './strand-formation-deadlines.js';
|
|
39
|
+
import {
|
|
40
|
+
FormationRejectedError,
|
|
41
|
+
FormationUnreachableError,
|
|
42
|
+
type FormationRejectionCode
|
|
43
|
+
} from './strand-formation-rejection.js';
|
|
44
|
+
|
|
45
|
+
const log = debug('sereus:cadre:formation-proto');
|
|
46
|
+
|
|
47
|
+
/** Protocol id for the native formation transport (parallel to `/sereus/seed/1.0.0`). */
|
|
48
|
+
export const FORMATION_PROTOCOL = '/sereus/formation/1.0.0';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Rejection reason for an invitation that cannot be redeemed — unknown here
|
|
52
|
+
* (`'token-unknown'`), or expired or fully spent (`'token-spent'`); the code tells them
|
|
53
|
+
* apart. Shared with `StrandFormationManager`, which reports an `InvitationExhaustedError`
|
|
54
|
+
* (an invite spent out from under a redemption already in flight) with this SAME wording and
|
|
55
|
+
* code on purpose: a joiner that lost the race must be indistinguishable from a non-racing
|
|
56
|
+
* latecomer, so the two sites must never drift apart.
|
|
57
|
+
*/
|
|
58
|
+
export const INVALID_TOKEN_REASON = 'Invalid token';
|
|
59
|
+
|
|
60
|
+
/** Maximum formation message size (1MB). */
|
|
61
|
+
const MAX_FORMATION_MSG_SIZE = 1024 * 1024;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Default cap on concurrent inbound formation sessions.
|
|
65
|
+
*
|
|
66
|
+
* NOTE: a session holds its slot for its whole provisioning budget, 171 s at the default
|
|
67
|
+
* declared link ({@link formationDeadlines}), so a slow hook or commit holds slots for
|
|
68
|
+
* minutes. Only a caller holding a valid token AND a valid consent signature reaches
|
|
69
|
+
* provisioning — a stranger is cut at the contact wait (7 s at the default) or at the first
|
|
70
|
+
* validation read — so the exposure is one invitee opening many sessions with one token. If
|
|
71
|
+
* invitation tokens are ever published where untrusted parties can read them, cap in-flight
|
|
72
|
+
* sessions per token rather than lowering this.
|
|
73
|
+
*/
|
|
74
|
+
const DEFAULT_MAX_CONCURRENT_SESSIONS = 100;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cap on the strand-network addresses a formation result carries
|
|
78
|
+
* ({@link FormationResultMessage.strandAddrs}). A node's own strand multiaddrs are a
|
|
79
|
+
* handful of entries (one per listen transport, plus a circuit-relay reservation), so
|
|
80
|
+
* 16 is generous for the honest case while keeping the result frame bounded against a
|
|
81
|
+
* peer that would pad it — either direction, since both sides run the list through
|
|
82
|
+
* {@link sanitizeStrandAddrs}. The initiator's kept copy is one arriving list, replaced by
|
|
83
|
+
* the next formation against the same strand, so the same bound holds there.
|
|
84
|
+
*/
|
|
85
|
+
const MAX_STRAND_ADDRS = 16;
|
|
86
|
+
|
|
87
|
+
// ── Roles ────────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export type FormationParty = 'initiator' | 'responder';
|
|
90
|
+
|
|
91
|
+
// ── Wire messages (length-prefixed JSON frames) ──────────────────────────────
|
|
92
|
+
|
|
93
|
+
export interface FormationStrandInfo {
|
|
94
|
+
strandId: string;
|
|
95
|
+
createdBy: FormationParty;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface FormationDbConnectionInfo {
|
|
99
|
+
endpoint: string;
|
|
100
|
+
credentialsRef: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface FormationProvisionResult {
|
|
104
|
+
strand: FormationStrandInfo;
|
|
105
|
+
/**
|
|
106
|
+
* The strand's membership key (closed-strand read-gating secret), delivered to a
|
|
107
|
+
* validated invitee for provision-then-record formation. Disclosed only AFTER token
|
|
108
|
+
* + disclosure validation, exactly like the responder identity/cadre — a rejected or
|
|
109
|
+
* already-used token discloses neither identity nor key. Absent for open strands.
|
|
110
|
+
*/
|
|
111
|
+
memberPrivateKey?: string;
|
|
112
|
+
/**
|
|
113
|
+
* The joiner's single-use membership invitation into the (closed, bound) host
|
|
114
|
+
* strand — issued by the responder's live strand runtime against `Strand.Invite`
|
|
115
|
+
* just before the consent row was recorded. Same disclosure timing as
|
|
116
|
+
* {@link memberPrivateKey}: present only on an approved result, absent for open
|
|
117
|
+
* strands and the responder-provisions path. Bounded and shape-checked on the
|
|
118
|
+
* initiator side ({@link isWellFormedMembershipInvite}) — a hostile responder's
|
|
119
|
+
* malformed field fails validation rather than crashing the initiator.
|
|
120
|
+
*/
|
|
121
|
+
membershipInvite?: StrandMembershipInvite;
|
|
122
|
+
dbConnectionInfo: FormationDbConnectionInfo;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Outcome of the responder's provisioning hook.
|
|
127
|
+
*
|
|
128
|
+
* Distinct from a bare {@link FormationProvisionResult} so the hook can REJECT a
|
|
129
|
+
* formation AFTER token + disclosure validation — e.g. a bound invite naming a host
|
|
130
|
+
* strand this responder has not yet converged on, or a concurrent-redemption collision.
|
|
131
|
+
* Without this channel such cases threw inside provisioning, the stream closed with no
|
|
132
|
+
* result frame, and the initiator saw a read-error/timeout instead of a clean
|
|
133
|
+
* `approved: false`. A rejection still discloses NO responder identity/cadre.
|
|
134
|
+
*/
|
|
135
|
+
export type ResponderProvisionOutcome =
|
|
136
|
+
| { approved: true; result: FormationProvisionResult }
|
|
137
|
+
| FormationRejection;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A refusal as the responder sends it: never without a code. Carries nothing else — no
|
|
141
|
+
* identity, addresses or keys — so a rejection discloses nothing about the responder.
|
|
142
|
+
*/
|
|
143
|
+
export interface FormationRejection {
|
|
144
|
+
approved: false;
|
|
145
|
+
code: FormationRejectionCode;
|
|
146
|
+
/** Human-facing text for logs; joiners branch on `code`. */
|
|
147
|
+
reason: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What the responder's token check yields; an invalid token names which way it failed. */
|
|
151
|
+
export type FormationTokenCheck =
|
|
152
|
+
| { valid: true }
|
|
153
|
+
| { valid: false; code: Extract<FormationRejectionCode, 'token-unknown' | 'token-spent'> };
|
|
154
|
+
|
|
155
|
+
/** Initiator → Responder: carries the real token + disclosure + initiator cadre. */
|
|
156
|
+
export interface FormationContactMessage {
|
|
157
|
+
/** The real invitation token. */
|
|
158
|
+
token: string;
|
|
159
|
+
/** Initiator's member key (peer id) — human-facing identity in logs and `FormStrandResult.memberKey`. */
|
|
160
|
+
partyId: string;
|
|
161
|
+
/**
|
|
162
|
+
* Initiator's base64url ed25519 public key — the key behind `partyId`, written to
|
|
163
|
+
* `FormationUsage.PeerKey` and covered by BOTH signed digests.
|
|
164
|
+
*/
|
|
165
|
+
peerKey: string;
|
|
166
|
+
/** Joiner-minted single-use nonce for THIS redemption; both signed digests cover it. */
|
|
167
|
+
usageStampId: string;
|
|
168
|
+
/**
|
|
169
|
+
* The joiner's signature over the `'consent'` digest (see `formationConsentMessage`
|
|
170
|
+
* in control-database.ts) — proof the named peer agreed to this redemption.
|
|
171
|
+
*/
|
|
172
|
+
peerSignature: string;
|
|
173
|
+
/** The real disclosure, carried verbatim. */
|
|
174
|
+
disclosure: StrandFormationDisclosure;
|
|
175
|
+
/** Initiator's real multiaddrs. */
|
|
176
|
+
cadrePeerAddrs: string[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Responder → Initiator: responder identity/cadre disclosed only after validation. */
|
|
180
|
+
export interface FormationResultMessage {
|
|
181
|
+
approved: boolean;
|
|
182
|
+
/**
|
|
183
|
+
* Why the responder refused; present iff `approved === false`. Optional on this wire type
|
|
184
|
+
* only because a frame from another version may omit it — the responder always sends one
|
|
185
|
+
* ({@link FormationRejection}), and the joiner reads a missing one as `'unrecognized'`.
|
|
186
|
+
*/
|
|
187
|
+
code?: FormationRejectionCode;
|
|
188
|
+
/** Human-facing text; present iff `approved === false`. */
|
|
189
|
+
reason?: string;
|
|
190
|
+
/** Responder's real party id (disclosed only after validation). */
|
|
191
|
+
partyId?: string;
|
|
192
|
+
/** Responder's real multiaddrs (omitted on rejection). */
|
|
193
|
+
cadrePeerAddrs?: string[];
|
|
194
|
+
/**
|
|
195
|
+
* The responder's live STRAND-network multiaddrs for the provisioned strand — the
|
|
196
|
+
* initiator's only cross-party discovery seed, since the strand-addr RPC that resolves
|
|
197
|
+
* a sibling's strand addrs is membership-gated and answers own-party callers only.
|
|
198
|
+
*
|
|
199
|
+
* Disclosed on the same terms as `partyId`/`cadrePeerAddrs`: only after token +
|
|
200
|
+
* disclosure validation AND an approving provisioning outcome; a rejection carries
|
|
201
|
+
* none. Signaling-first, each entry ending in `/p2p/<responder strand transport
|
|
202
|
+
* peerId>` so the initiator can attribute it. OMITTED when the responder holds no live
|
|
203
|
+
* strand node for the provisioned strand (the responder-provisions path mints a strand
|
|
204
|
+
* that has not launched yet) — an absent or empty list is legal and means "no
|
|
205
|
+
* cross-party seed", never a protocol error.
|
|
206
|
+
*/
|
|
207
|
+
strandAddrs?: string[];
|
|
208
|
+
/** The provisioned strand/db result (always present on approval). */
|
|
209
|
+
provisionResult?: FormationProvisionResult;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** An approving result frame as the responder builds it. */
|
|
213
|
+
type ApprovedResultMessage = FormationResultMessage & { approved: true };
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Normalize a strand-address list arriving from — or heading to — the wire: keep only
|
|
217
|
+
* parsable multiaddr strings, drop duplicates, and cap at {@link MAX_STRAND_ADDRS}.
|
|
218
|
+
* Order is preserved, so the responder's signaling-first ordering survives.
|
|
219
|
+
*
|
|
220
|
+
* Applied on BOTH sides on purpose. The responder bounds what it sends; the initiator
|
|
221
|
+
* bounds what it stores, because a peer is free to ignore the cap. A malformed entry is
|
|
222
|
+
* SKIPPED rather than fatal — these are runtime-discovered addresses (same posture as
|
|
223
|
+
* `extractCircuitRelayTargets` in `delegate-admission.ts`), and one bad entry must not
|
|
224
|
+
* cost a formation that has otherwise succeeded.
|
|
225
|
+
*/
|
|
226
|
+
export function sanitizeStrandAddrs(addrs: unknown): string[] {
|
|
227
|
+
if (!Array.isArray(addrs)) return [];
|
|
228
|
+
const out: string[] = [];
|
|
229
|
+
const seen = new Set<string>();
|
|
230
|
+
for (const entry of addrs) {
|
|
231
|
+
if (out.length >= MAX_STRAND_ADDRS) break;
|
|
232
|
+
if (typeof entry !== 'string' || entry.length === 0) {
|
|
233
|
+
log('strandAddrs: skipping non-string entry %o', entry);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (seen.has(entry)) continue;
|
|
237
|
+
try {
|
|
238
|
+
multiaddr(entry);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
// Deliberately not echoing the raw value at error level: it arrived from a remote
|
|
241
|
+
// peer. The log line below is the observability for a responder announcing junk.
|
|
242
|
+
log('strandAddrs: skipping unparsable entry: %o', error);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
seen.add(entry);
|
|
246
|
+
out.push(entry);
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Stream framing helpers ───────────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Reads length-prefixed JSON frames from a libp2p stream one frame at a time.
|
|
255
|
+
*
|
|
256
|
+
* Unlike seed delivery (which reads a stream to EOF then parses once), formation
|
|
257
|
+
* is request/response on a single live stream, so reading must stop at each frame
|
|
258
|
+
* boundary. The reader buffers across chunk boundaries and retains any overflow
|
|
259
|
+
* (a following frame coalesced into the same chunk) for the next read.
|
|
260
|
+
*/
|
|
261
|
+
class FrameReader {
|
|
262
|
+
private readonly iterator: AsyncIterator<Uint8Array>;
|
|
263
|
+
private readonly maxLength: number;
|
|
264
|
+
private buffer = new Uint8Array(0);
|
|
265
|
+
|
|
266
|
+
constructor(stream: AsyncIterable<Uint8Array>, maxLength = MAX_FORMATION_MSG_SIZE) {
|
|
267
|
+
this.iterator = stream[Symbol.asyncIterator]();
|
|
268
|
+
this.maxLength = maxLength;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private append(chunk: Uint8Array): void {
|
|
272
|
+
const next = new Uint8Array(this.buffer.length + chunk.length);
|
|
273
|
+
next.set(this.buffer, 0);
|
|
274
|
+
next.set(chunk, this.buffer.length);
|
|
275
|
+
this.buffer = next;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Pull chunks until the buffer holds ≥ `need` bytes; false if the stream ended first. */
|
|
279
|
+
private async fill(need: number): Promise<boolean> {
|
|
280
|
+
while (this.buffer.length < need) {
|
|
281
|
+
const { value, done } = await this.iterator.next();
|
|
282
|
+
if (done) return false;
|
|
283
|
+
const bytes = value instanceof Uint8Array
|
|
284
|
+
? value
|
|
285
|
+
: (value as { subarray(): Uint8Array }).subarray();
|
|
286
|
+
this.append(bytes);
|
|
287
|
+
}
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async read<T>(): Promise<T> {
|
|
292
|
+
if (!(await this.fill(4))) {
|
|
293
|
+
throw new Error('Formation stream closed before length prefix');
|
|
294
|
+
}
|
|
295
|
+
const length = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength).getUint32(0, false);
|
|
296
|
+
if (length > this.maxLength) {
|
|
297
|
+
throw new Error(`Formation frame declares length ${length} exceeding max ${this.maxLength}`);
|
|
298
|
+
}
|
|
299
|
+
if (!(await this.fill(4 + length))) {
|
|
300
|
+
throw new Error(`Formation frame declares length ${length} but stream ended early`);
|
|
301
|
+
}
|
|
302
|
+
const json = new TextDecoder().decode(this.buffer.subarray(4, 4 + length));
|
|
303
|
+
this.buffer = this.buffer.subarray(4 + length);
|
|
304
|
+
return JSON.parse(json) as T;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// ── Result validation ────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
/** Matches the placeholder cadre addrs (`cadre-a-1.local`, …) of the removed strand-proto transport. */
|
|
311
|
+
const PLACEHOLDER_CADRE_ADDR = /^cadre-[ab]-\d+\.local$/;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Both halves of a {@link StrandMembershipInvite} are base64url ed25519 key encodings —
|
|
315
|
+
* 43 chars for 32 bytes. The bound is deliberately loose (a future encoding change must
|
|
316
|
+
* not silently strand joiners) while still capping what a hostile responder can make the
|
|
317
|
+
* initiator retain.
|
|
318
|
+
*/
|
|
319
|
+
const MAX_MEMBERSHIP_INVITE_KEY_LENGTH = 256;
|
|
320
|
+
const BASE64URL_KEY = /^[A-Za-z0-9_-]+$/;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Is `value` a well-formed {@link StrandMembershipInvite} — exactly two bounded
|
|
324
|
+
* base64url strings? The initiator-side shape check for the one attacker-influenced
|
|
325
|
+
* OBJECT the formation result carries (`strandAddrs` gets the equivalent treatment in
|
|
326
|
+
* {@link sanitizeStrandAddrs}): a malformed field from a hostile responder must fail
|
|
327
|
+
* validation, never crash the initiator or be carried into the joiner's invite cache.
|
|
328
|
+
* `undefined` is NOT well-formed — absence is legal on the result and callers gate on
|
|
329
|
+
* presence first.
|
|
330
|
+
*/
|
|
331
|
+
export function isWellFormedMembershipInvite(value: unknown): value is StrandMembershipInvite {
|
|
332
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
333
|
+
const { inviteKey, invitePrivateKey } = value as Record<string, unknown>;
|
|
334
|
+
const wellFormedKey = (key: unknown): boolean =>
|
|
335
|
+
typeof key === 'string' && key.length > 0 && key.length <= MAX_MEMBERSHIP_INVITE_KEY_LENGTH && BASE64URL_KEY.test(key);
|
|
336
|
+
return wellFormedKey(inviteKey) && wellFormedKey(invitePrivateKey);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Structural check on a `responderCreates` result: the responder must have approved,
|
|
341
|
+
* disclosed a non-empty real identity + cadre (not the legacy `cadre-*.local`
|
|
342
|
+
* placeholders), and returned a strand vouched for by the responder party with a real id.
|
|
343
|
+
*
|
|
344
|
+
* `createdBy: 'responder'` means "the responder party vouches for / returns this strand."
|
|
345
|
+
* Under provision-then-record that strand was minted owner-signed earlier (not created
|
|
346
|
+
* in-session), but it is still the responder party returning its own strand, so the marker
|
|
347
|
+
* — and this structural validator — stay unchanged.
|
|
348
|
+
*
|
|
349
|
+
* The behavioral floor for the initiator: a responder that returns an arbitrary/empty
|
|
350
|
+
* `strandId` or omits its disclosed identity is rejected, not silently accepted.
|
|
351
|
+
*/
|
|
352
|
+
export function isValidResponderCreatesResult(response: FormationResultMessage): boolean {
|
|
353
|
+
if (!response.approved) return false;
|
|
354
|
+
if (!response.partyId) return false;
|
|
355
|
+
const addrs = response.cadrePeerAddrs;
|
|
356
|
+
if (!addrs || addrs.length === 0) return false;
|
|
357
|
+
if (addrs.some((a) => PLACEHOLDER_CADRE_ADDR.test(a))) return false;
|
|
358
|
+
const provision = response.provisionResult;
|
|
359
|
+
if (!provision?.strand?.strandId) return false;
|
|
360
|
+
if (provision.strand.createdBy !== 'responder') return false;
|
|
361
|
+
// Optional, but when present it must be well-formed: a malformed invitation is a
|
|
362
|
+
// membership the joiner can never redeem, so reject the result rather than accept a
|
|
363
|
+
// formation that would leave a half-member.
|
|
364
|
+
if (provision.membershipInvite !== undefined && !isWellFormedMembershipInvite(provision.membershipInvite)) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Responder (listener) ─────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
export interface FormationListenerOptions {
|
|
373
|
+
/** Validate the invitation token; an invalid one says whether it is unknown here or spent. */
|
|
374
|
+
validateToken(token: string): Promise<FormationTokenCheck>;
|
|
375
|
+
/** Validate the initiator's disclosure with the REAL token + disclosure. */
|
|
376
|
+
validateDisclosure(token: string, disclosure: StrandFormationDisclosure): Promise<boolean>;
|
|
377
|
+
/**
|
|
378
|
+
* Provision (or, for provision-then-record, resolve + record consent against) the
|
|
379
|
+
* strand (`responderCreates`) for the given initiator. The WHOLE contact message is
|
|
380
|
+
* threaded in: the hook needs the REAL token to map the bound host strand, and the
|
|
381
|
+
* joiner's `peerKey`/`usageStampId`/`peerSignature` to write the consent columns.
|
|
382
|
+
*
|
|
383
|
+
* Returns a {@link ResponderProvisionOutcome}: the hook may REJECT post-validation
|
|
384
|
+
* (e.g. an unconverged host strand) and the listener turns that into a clean,
|
|
385
|
+
* non-disclosing `approved: false` reply rather than dropping the result frame.
|
|
386
|
+
*
|
|
387
|
+
* `signal` is aborted when the listener's work budget expires: a hook that observes it
|
|
388
|
+
* BEFORE writing abandons the redemption and leaves the invite unspent. Optional so
|
|
389
|
+
* signal-unaware hooks (tests, mocks) stay assignable.
|
|
390
|
+
*/
|
|
391
|
+
provisionStrand(contact: FormationContactMessage, signal?: AbortSignal): Promise<ResponderProvisionOutcome>;
|
|
392
|
+
/** Responder identity, disclosed only AFTER token + disclosure validation passes. */
|
|
393
|
+
getResponderIdentity(): { partyId: string; cadrePeerAddrs: string[] };
|
|
394
|
+
/**
|
|
395
|
+
* This node's live STRAND-network multiaddrs for a provisioned strand, read on the
|
|
396
|
+
* approval path only (same disclosure timing as {@link getResponderIdentity}) and
|
|
397
|
+
* carried back as {@link FormationResultMessage.strandAddrs}.
|
|
398
|
+
*
|
|
399
|
+
* Optional, and an empty answer is legal: the strand may not be running here yet (the
|
|
400
|
+
* responder-provisions path mints it during this very session). The listener cannot
|
|
401
|
+
* reach the strand runtime itself, so this is the seam `CadreNode` wires to its own
|
|
402
|
+
* per-strand address lookup.
|
|
403
|
+
*/
|
|
404
|
+
resolveStrandAddrs?(strandId: string): string[];
|
|
405
|
+
/**
|
|
406
|
+
* This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
|
|
407
|
+
* every deadline below takes its default ({@link formationDeadlines}). The default
|
|
408
|
+
* declaration when omitted.
|
|
409
|
+
*/
|
|
410
|
+
linkRoundTripMs?: number;
|
|
411
|
+
/** Whole-session budget; default `sessionMs` of the derived ladder. */
|
|
412
|
+
sessionTimeoutMs?: number;
|
|
413
|
+
/** Budget for the contact-frame read; default `awaitContactMs` of the derived ladder. */
|
|
414
|
+
stepTimeoutMs?: number;
|
|
415
|
+
/**
|
|
416
|
+
* Budget for the `provisionStrand` hook call, work plus settle grace — distinct from
|
|
417
|
+
* `stepTimeoutMs` because provisioning is real work, not a bare wire read. Default
|
|
418
|
+
* `provisionWorkMs + provisionGraceMs` of the derived ladder (171 s at the default
|
|
419
|
+
* declaration); `0`/unset uses the default; a value that would outlive the session is
|
|
420
|
+
* clamped ({@link resolveProvisionTimeoutMs}). See {@link formationDeadlines} for the ordering.
|
|
421
|
+
*/
|
|
422
|
+
provisionTimeoutMs?: number;
|
|
423
|
+
maxConcurrentSessions?: number;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Responder side of the native formation protocol. Registers a libp2p handler and
|
|
428
|
+
* drives each inbound stream through token + disclosure validation, provisioning,
|
|
429
|
+
* and result delivery — enforcing the cadre-disclosure timing rule (responder cadre
|
|
430
|
+
* is revealed only after validation; rejections disclose nothing).
|
|
431
|
+
*/
|
|
432
|
+
export class FormationListener {
|
|
433
|
+
private readonly options: FormationListenerOptions;
|
|
434
|
+
private readonly sessionTimeoutMs: number;
|
|
435
|
+
private readonly awaitContactMs: number;
|
|
436
|
+
private readonly provisionWorkMs: number;
|
|
437
|
+
private readonly provisionGraceMs: number;
|
|
438
|
+
private readonly maxConcurrentSessions: number;
|
|
439
|
+
private readonly registered = new Set<Libp2p>();
|
|
440
|
+
private activeSessions = 0;
|
|
441
|
+
private sessionCounter = 0;
|
|
442
|
+
|
|
443
|
+
constructor(options: FormationListenerOptions) {
|
|
444
|
+
this.options = options;
|
|
445
|
+
const deadlines = formationDeadlines(options.linkRoundTripMs);
|
|
446
|
+
this.sessionTimeoutMs = options.sessionTimeoutMs ?? deadlines.sessionMs;
|
|
447
|
+
this.awaitContactMs = options.stepTimeoutMs ?? deadlines.awaitContactMs;
|
|
448
|
+
const split = splitProvisionBudget(resolveProvisionTimeoutMs(
|
|
449
|
+
options.provisionTimeoutMs, deadlines.provisionWorkMs + deadlines.provisionGraceMs,
|
|
450
|
+
this.sessionTimeoutMs, this.awaitContactMs + deadlines.validationMs, 'FormationListener',
|
|
451
|
+
responderClampReserveMs(deadlines, this.awaitContactMs)
|
|
452
|
+
), deadlines.provisionGraceMs);
|
|
453
|
+
this.provisionWorkMs = split.workMs;
|
|
454
|
+
this.provisionGraceMs = split.graceMs;
|
|
455
|
+
this.maxConcurrentSessions = options.maxConcurrentSessions ?? DEFAULT_MAX_CONCURRENT_SESSIONS;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Number of in-flight inbound sessions. */
|
|
459
|
+
get activeCount(): number {
|
|
460
|
+
return this.activeSessions;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Register the formation handler on `node`. Rejects when libp2p refuses the registration. */
|
|
464
|
+
async register(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): Promise<void> {
|
|
465
|
+
if (this.registered.has(node)) {
|
|
466
|
+
log('node already registered');
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
await node.handle(protocolId, async (rawStream: unknown, _connection: unknown) => {
|
|
470
|
+
await this.handleStream(rawStream as ControlStream);
|
|
471
|
+
});
|
|
472
|
+
this.registered.add(node);
|
|
473
|
+
log('formation listener registered (%s)', protocolId);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async unregister(node: Libp2p, protocolId: string = FORMATION_PROTOCOL): Promise<void> {
|
|
477
|
+
if (!this.registered.has(node)) return;
|
|
478
|
+
await node.unhandle(protocolId);
|
|
479
|
+
this.registered.delete(node);
|
|
480
|
+
log('formation listener unregistered (%s)', protocolId);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private async handleStream(stream: ControlStream): Promise<void> {
|
|
484
|
+
if (this.activeSessions >= this.maxConcurrentSessions) {
|
|
485
|
+
const rejection: FormationRejection = { approved: false, code: 'busy', reason: 'Too many concurrent formation sessions' };
|
|
486
|
+
try { writeFrame(stream, rejection); } catch { /* best effort */ }
|
|
487
|
+
try { await stream.close(); } catch { /* ignore */ }
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const id = ++this.sessionCounter;
|
|
491
|
+
this.activeSessions++;
|
|
492
|
+
try {
|
|
493
|
+
await withTimeout(this.sessionTimeoutMs, `Formation session#${id}`, () => this.runSession(id, stream));
|
|
494
|
+
} catch (err) {
|
|
495
|
+
log('formation session #%d failed: %o', id, err);
|
|
496
|
+
} finally {
|
|
497
|
+
this.activeSessions--;
|
|
498
|
+
try { await stream.close(); } catch { /* ignore */ }
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Run the provisioning hook under its WORK budget — the provisioning budget minus the
|
|
504
|
+
* trailing settle grace ({@link splitProvisionBudget}).
|
|
505
|
+
*
|
|
506
|
+
* When the work budget expires the hook's signal is aborted, then
|
|
507
|
+
* {@link settleWithinGrace} waits out the grace for the aborted work to settle anyway.
|
|
508
|
+
* Returns `undefined` for "timed out" — which the caller turns into a retryable rejection
|
|
509
|
+
* frame — and rethrows every other failure so it reaches the internal-error path.
|
|
510
|
+
*
|
|
511
|
+
* Deliberately not `withDeadline`: that exposes neither the timed-out flag nor the signal
|
|
512
|
+
* outside its `op`, and both are needed here.
|
|
513
|
+
*/
|
|
514
|
+
private async provision(id: number, contact: FormationContactMessage): Promise<ResponderProvisionOutcome | undefined> {
|
|
515
|
+
const controller = new AbortController();
|
|
516
|
+
let timedOut = false;
|
|
517
|
+
const pending = this.options.provisionStrand(contact, controller.signal);
|
|
518
|
+
try {
|
|
519
|
+
return await withTimeout(
|
|
520
|
+
this.provisionWorkMs,
|
|
521
|
+
`Formation provisioning#${id}`,
|
|
522
|
+
() => pending,
|
|
523
|
+
() => { timedOut = true; controller.abort(); }
|
|
524
|
+
);
|
|
525
|
+
} catch (err) {
|
|
526
|
+
if (!timedOut) throw err;
|
|
527
|
+
log(
|
|
528
|
+
'formation session #%d provisioning work budget expired after %dms; aborted, settling up to %dms',
|
|
529
|
+
id, this.provisionWorkMs, this.provisionGraceMs
|
|
530
|
+
);
|
|
531
|
+
return await this.settleWithinGrace(id, pending);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Wait out the settle grace for an ABORTED provisioning, adopting a late success.
|
|
537
|
+
*
|
|
538
|
+
* A hook that observed the abort before issuing its `FormationUsage` insert rejects (with
|
|
539
|
+
* `FormationAbortedError`) and leaves the invite unspent → reported as a timeout. A hook
|
|
540
|
+
* that had already written cannot be un-written (the table is append-only), so if it lands
|
|
541
|
+
* inside the grace its outcome is ADOPTED — the joiner is told the truth (approved, or the
|
|
542
|
+
* hook's own rejection reason) rather than "timed out" over a spent invite.
|
|
543
|
+
*
|
|
544
|
+
* The grace is derived to contain one seat read plus one commit at the declared link
|
|
545
|
+
* ({@link formationDeadlines}), which is exactly what runs after the insert attempt passes
|
|
546
|
+
* its abort check, so at that link the late outcome lands inside it.
|
|
547
|
+
*
|
|
548
|
+
* NOTE: a commit that outlasts even the derived grace (a link slower than declared, a
|
|
549
|
+
* configured `provisionTimeoutMs` whose half-cap shrank the grace, or a commit phase that
|
|
550
|
+
* re-picks an unanswering coordinator, which Optimystic lets run to its transaction budget,
|
|
551
|
+
* 170 s at the default declared link) still tells the joiner
|
|
552
|
+
* 'timed out' while its one-time invite is in fact spent, and since every retry mints a
|
|
553
|
+
* fresh keypair no recovery path can match it. Nothing can un-spend an append-only row, so
|
|
554
|
+
* the late-settle logging below is the observability for it; if it is seen in the wild,
|
|
555
|
+
* raise the declared link rather than this grace.
|
|
556
|
+
*/
|
|
557
|
+
private async settleWithinGrace(
|
|
558
|
+
id: number,
|
|
559
|
+
pending: Promise<ResponderProvisionOutcome>
|
|
560
|
+
): Promise<ResponderProvisionOutcome | undefined> {
|
|
561
|
+
let stillPending = false;
|
|
562
|
+
try {
|
|
563
|
+
const outcome = await withTimeout(
|
|
564
|
+
this.provisionGraceMs,
|
|
565
|
+
`Formation settle#${id}`,
|
|
566
|
+
() => pending,
|
|
567
|
+
() => { stillPending = true; }
|
|
568
|
+
);
|
|
569
|
+
log('formation session #%d provisioning settled within its grace: approved=%s — adopting', id, outcome.approved);
|
|
570
|
+
return outcome;
|
|
571
|
+
} catch (err) {
|
|
572
|
+
if (!stillPending) {
|
|
573
|
+
log('formation session #%d provisioning failed after its abort: %o', id, err);
|
|
574
|
+
return undefined;
|
|
575
|
+
}
|
|
576
|
+
log('formation session #%d provisioning still pending after its %dms grace', id, this.provisionGraceMs);
|
|
577
|
+
// Log how it eventually settled so a late failure is not swallowed by the two
|
|
578
|
+
// abandoned `withTimeout` calls.
|
|
579
|
+
void pending.then(
|
|
580
|
+
(late) => log('formation session #%d provisioning settled after its grace: approved=%s', id, late.approved),
|
|
581
|
+
(err2) => log('formation session #%d provisioning failed after its grace: %o', id, err2)
|
|
582
|
+
);
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Joiner-consent pre-check — the legibility twin of the schema's
|
|
589
|
+
* `FormationUsage.PeerConsented` CHECK, exactly as `verifyFormationApproval` is for the
|
|
590
|
+
* approver's vouch. The database constraint stays the authority; this turns a bad consent
|
|
591
|
+
* into a clean 'Invalid joiner consent' rejection instead of an opaque CHECK failure at
|
|
592
|
+
* commit. Runs BEFORE token/disclosure validation: it is the cheapest check (pure local
|
|
593
|
+
* crypto, no DB read) and a rejection discloses nothing.
|
|
594
|
+
*
|
|
595
|
+
* Also pins `partyId` to `peerKey`: an ed25519 peer id is an identity multihash of the
|
|
596
|
+
* very key, and this is the ONLY layer that can check the match — SQL cannot unwrap a
|
|
597
|
+
* multihash, and `partyId` itself is never stored.
|
|
598
|
+
*/
|
|
599
|
+
private isJoinerConsentValid(id: number, contact: FormationContactMessage): boolean {
|
|
600
|
+
try {
|
|
601
|
+
requireEd25519PublicKeyB64(contact.peerKey, 'joiner peer key');
|
|
602
|
+
} catch {
|
|
603
|
+
// Deliberately not rethrown or echoed: the helper's error message embeds the whole
|
|
604
|
+
// rejected value, and this one arrived from a remote peer (the cap-the-echo case its
|
|
605
|
+
// own NOTE warns about). Log without the raw value instead.
|
|
606
|
+
log('formation session #%d: joiner peer key is not a base64url ed25519 public key', id);
|
|
607
|
+
return false;
|
|
608
|
+
}
|
|
609
|
+
const embedded = ed25519PublicKeyB64FromPeerId(contact.partyId);
|
|
610
|
+
if (!embedded || embedded !== contact.peerKey) {
|
|
611
|
+
log('formation session #%d: partyId does not embed the presented peer key', id);
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
const consentOk = verifyFormationConsent({
|
|
615
|
+
token: contact.token,
|
|
616
|
+
usageStampId: contact.usageStampId,
|
|
617
|
+
peerKey: contact.peerKey,
|
|
618
|
+
disclosure: canonicalJson(contact.disclosure),
|
|
619
|
+
peerSig: contact.peerSignature
|
|
620
|
+
});
|
|
621
|
+
if (!consentOk) {
|
|
622
|
+
log('formation session #%d: joiner consent signature failed verification', id);
|
|
623
|
+
return false;
|
|
624
|
+
}
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* The strand addresses to disclose for an APPROVED provisioning, sanitized and capped.
|
|
630
|
+
*
|
|
631
|
+
* Never throws: a hook that fails (a strand runtime torn down mid-session) costs the
|
|
632
|
+
* joiner its cross-party seed, not the formation it has already earned — the seed is
|
|
633
|
+
* an optimization, the consent row is the commitment.
|
|
634
|
+
*/
|
|
635
|
+
private strandAddrsFor(id: number, strandId: string): string[] {
|
|
636
|
+
if (!this.options.resolveStrandAddrs) return [];
|
|
637
|
+
try {
|
|
638
|
+
return sanitizeStrandAddrs(this.options.resolveStrandAddrs(strandId));
|
|
639
|
+
} catch (err) {
|
|
640
|
+
log('formation session #%d: strand-addr lookup for %s failed (continuing): %o', id, strandId, err);
|
|
641
|
+
return [];
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private async runSession(id: number, stream: ControlStream): Promise<void> {
|
|
646
|
+
// Track whether ANY frame has been written so the catch below can convert an
|
|
647
|
+
// unexpected internal error into a non-disclosing rejection ONLY when nothing has
|
|
648
|
+
// gone out yet. This closes the "stream closed with no result frame" class of bug.
|
|
649
|
+
let wroteFrame = false;
|
|
650
|
+
const send = (msg: FormationRejection | ApprovedResultMessage): void => {
|
|
651
|
+
writeFrame(stream, msg);
|
|
652
|
+
wroteFrame = true;
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
try {
|
|
656
|
+
const reader = new FrameReader(stream);
|
|
657
|
+
const contact = await withTimeout(this.awaitContactMs, `Formation await-contact#${id}`, () => reader.read<FormationContactMessage>());
|
|
658
|
+
log('formation session #%d contact: token=%s party=%s', id, contact.token, contact.partyId);
|
|
659
|
+
|
|
660
|
+
if (!this.isJoinerConsentValid(id, contact)) {
|
|
661
|
+
send({ approved: false, code: 'consent-invalid', reason: 'Invalid joiner consent' });
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const tokenResult = await this.options.validateToken(contact.token);
|
|
666
|
+
if (!tokenResult.valid) {
|
|
667
|
+
send({ approved: false, code: tokenResult.code, reason: INVALID_TOKEN_REASON });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const disclosureOk = await this.options.validateDisclosure(contact.token, contact.disclosure);
|
|
672
|
+
if (!disclosureOk) {
|
|
673
|
+
send({ approved: false, code: 'disclosure-invalid', reason: 'Invalid disclosure' });
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const outcome = await this.provision(id, contact);
|
|
678
|
+
if (!outcome) {
|
|
679
|
+
// Reported as its own retryable reason rather than falling through to the generic
|
|
680
|
+
// catch below, which would report the misleading 'Internal formation error'.
|
|
681
|
+
send({ approved: false, code: 'provisioning-timeout', reason: 'Formation provisioning timed out' });
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (!outcome.approved) {
|
|
685
|
+
// A post-validation rejection still discloses NEITHER identity NOR cadre, exactly
|
|
686
|
+
// like the token/disclosure rejections above — copied field by field so nothing else
|
|
687
|
+
// a hook put on its outcome reaches the wire.
|
|
688
|
+
send({ approved: false, code: outcome.code, reason: outcome.reason });
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
// Validation + provisioning passed → safe to disclose responder identity/cadre.
|
|
692
|
+
const identity = this.options.getResponderIdentity();
|
|
693
|
+
const strandAddrs = this.strandAddrsFor(id, outcome.result.strand.strandId);
|
|
694
|
+
send({
|
|
695
|
+
approved: true,
|
|
696
|
+
partyId: identity.partyId,
|
|
697
|
+
cadrePeerAddrs: identity.cadrePeerAddrs,
|
|
698
|
+
// Omitted rather than sent empty: an absent field and an empty list mean the same
|
|
699
|
+
// thing to the initiator, and omitting keeps every rejection-parity assertion a
|
|
700
|
+
// plain `toBeUndefined()`.
|
|
701
|
+
...(strandAddrs.length > 0 ? { strandAddrs } : {}),
|
|
702
|
+
provisionResult: outcome.result
|
|
703
|
+
});
|
|
704
|
+
} catch (err) {
|
|
705
|
+
// Defense-in-depth: if an unexpected internal error escapes BEFORE any frame was
|
|
706
|
+
// written (e.g. a future provisionStrand-hook bug), convert it into a non-disclosing
|
|
707
|
+
// protocol rejection so the initiator sees a clean error instead of a read-error/
|
|
708
|
+
// timeout. If a frame was already sent, leave the stream as-is and let handleStream
|
|
709
|
+
// log + close. Re-throw either way so the failure is still recorded — this is a
|
|
710
|
+
// deliberate, logged conversion, not silent.
|
|
711
|
+
if (!wroteFrame) {
|
|
712
|
+
const internalError: FormationRejection = { approved: false, code: 'internal', reason: 'Internal formation error' };
|
|
713
|
+
try { writeFrame(stream, internalError); } catch { /* stream already broken */ }
|
|
714
|
+
}
|
|
715
|
+
throw err;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ── Initiator (dialer) ───────────────────────────────────────────────────────
|
|
721
|
+
|
|
722
|
+
export interface FormationDialOptions {
|
|
723
|
+
/** The contact message to send (real token, partyId, disclosure, initiator cadre). */
|
|
724
|
+
contact: FormationContactMessage;
|
|
725
|
+
/**
|
|
726
|
+
* Responder multiaddrs to dial, normally the invitation's bootstrap list. Every entry that
|
|
727
|
+
* parses is tried, in the order libp2p's default address sorter picks (loopback last, then
|
|
728
|
+
* public before private, then circuit after direct), until one connects, all under one
|
|
729
|
+
* dial-connect budget (`dialTimeoutMs`); an entry that does not parse is skipped.
|
|
730
|
+
*
|
|
731
|
+
* {@link dialFormation} dials ONE machine, so every entry must name the same trailing peer
|
|
732
|
+
* id (or none). An invitation names several machines of the inviting party; hand it to
|
|
733
|
+
* {@link dialFormationByMachine}, which splits the list by trailing peer id, drops entries
|
|
734
|
+
* that name no peer, and runs one {@link dialFormation} session per machine, in the order
|
|
735
|
+
* each machine first appears. Each session keeps its own deadline ladder, so a machine that
|
|
736
|
+
* cannot be dialled costs one `dialTimeoutMs` and one that accepts the contact and then
|
|
737
|
+
* stalls costs up to one `sessionTimeoutMs`; `CadreNode.createOpenInvitation` names at most
|
|
738
|
+
* four machines.
|
|
739
|
+
*/
|
|
740
|
+
responderAddrs: string[];
|
|
741
|
+
/** Validate the responder's result; a false return aborts the formation. */
|
|
742
|
+
validateResponse(response: FormationResultMessage): Promise<boolean>;
|
|
743
|
+
/**
|
|
744
|
+
* This machine's declared link round trip (`NetworkConfig.linkRoundTripMs`), from which
|
|
745
|
+
* every deadline below takes its default ({@link formationDeadlines}). The default
|
|
746
|
+
* declaration when omitted.
|
|
747
|
+
*/
|
|
748
|
+
linkRoundTripMs?: number;
|
|
749
|
+
/** Whole-session budget; default `sessionMs` of the derived ladder. */
|
|
750
|
+
sessionTimeoutMs?: number;
|
|
751
|
+
/**
|
|
752
|
+
* Budget for `dial-connect`: opening the connection, through a relay if need be, and
|
|
753
|
+
* negotiating the formation protocol. Default `dialMs` of the derived ladder.
|
|
754
|
+
*/
|
|
755
|
+
dialTimeoutMs?: number;
|
|
756
|
+
/**
|
|
757
|
+
* Budget for the `await-response` read only (the responder's provisioning plus the reply's
|
|
758
|
+
* travel time). Default `initiatorAwaitResponseMs` of the derived ladder; `0`/unset uses
|
|
759
|
+
* the default; a value that would outlive the session is clamped. See
|
|
760
|
+
* {@link formationDeadlines} for the ordering.
|
|
761
|
+
*/
|
|
762
|
+
provisionTimeoutMs?: number;
|
|
763
|
+
protocolId?: string;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* What one successful formation dial yields the initiator.
|
|
768
|
+
*
|
|
769
|
+
* The provisioned strand travels in {@link FormationProvisionResult}, but the responder's
|
|
770
|
+
* strand-network addresses are NOT part of that structure — they are a disclosure of the
|
|
771
|
+
* responder's live runtime, alongside `partyId`/`cadrePeerAddrs`, not a property of the
|
|
772
|
+
* strand row. Returning them beside the provision result keeps that separation while
|
|
773
|
+
* still handing the caller the one thing it needs to seed a cross-party mesh.
|
|
774
|
+
*
|
|
775
|
+
* `strandAddrs` is always an array and is frequently EMPTY — see
|
|
776
|
+
* {@link FormationResultMessage.strandAddrs}.
|
|
777
|
+
*/
|
|
778
|
+
export interface FormationDialResult {
|
|
779
|
+
provision: FormationProvisionResult;
|
|
780
|
+
/** Sanitized + capped ({@link sanitizeStrandAddrs}); `[]` when the responder sent none. */
|
|
781
|
+
strandAddrs: string[];
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Open the formation stream under the dial deadline, cancelling the dial when it expires.
|
|
786
|
+
*
|
|
787
|
+
* The deadline's signal goes into `dialProtocol`, so an expired dial is abandoned by libp2p
|
|
788
|
+
* rather than left running. A dial that settles in the same tick the deadline fires loses
|
|
789
|
+
* the race to the timeout rejection and would leave its stream open with nobody to close
|
|
790
|
+
* it, so a stream that arrives after the signal aborted is reset here — the same treatment
|
|
791
|
+
* `exchangeFrame` (`control-stream.ts`) gives a stream dialed past its deadline. A late
|
|
792
|
+
* rejection is the abort itself, or reaches the caller through the awaited copy, so the
|
|
793
|
+
* handler below only stops it being reported as unhandled.
|
|
794
|
+
*
|
|
795
|
+
* NOTE: libp2p tries `addrs` one after another, and they all share this one dial budget. Its
|
|
796
|
+
* per-address limit (`addressDialTimeout`: ten link round trips plus two admission decisions, as
|
|
797
|
+
* `optimysticDialLimits` states it) is longer than `dialTimeoutMs` (`formationDeadlines().dialMs`:
|
|
798
|
+
* five round trips plus one decision) at every declaration, so it never cuts off an address
|
|
799
|
+
* inside this dial. An address that hangs without answering (a black-holed relay host) uses the
|
|
800
|
+
* whole budget, and the join moves on to the party's next machine
|
|
801
|
+
* ({@link dialFormationByMachine}) rather than to this machine's next address; an address that is
|
|
802
|
+
* refused fails fast and still hands over to the next. A budget per address here would overrun
|
|
803
|
+
* the session or shrink the await-response budget ({@link formationDeadlines}). If joins through a
|
|
804
|
+
* hung first address are seen in practice, give each address a sub-budget or dial them in parallel.
|
|
805
|
+
* The same dial can also be joined to another dial of the responder in flight (the queue keys
|
|
806
|
+
* jobs by peer id — see `peer-dial.ts` → `dialAddr`), which then holds this one on that job's
|
|
807
|
+
* address; the remedy, when that is seen, is the same per-address dial.
|
|
808
|
+
*/
|
|
809
|
+
function openFormationStream(node: Libp2p, addrs: Multiaddr[], protocolId: string, dialTimeoutMs: number): Promise<ControlStream> {
|
|
810
|
+
return withDeadline(dialTimeoutMs, 'Formation dial-connect', async (signal) => {
|
|
811
|
+
const pending = node.dialProtocol(addrs, protocolId, { signal });
|
|
812
|
+
void pending.then(
|
|
813
|
+
(stream) => {
|
|
814
|
+
if (signal.aborted) {
|
|
815
|
+
(stream as unknown as ControlStream).abort(new Error('Formation dial-connect resolved after its deadline'));
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
() => { /* surfaced through the awaited copy, or the abort itself */ }
|
|
819
|
+
);
|
|
820
|
+
try {
|
|
821
|
+
return await pending as unknown as ControlStream;
|
|
822
|
+
} catch (error) {
|
|
823
|
+
throw describeAllAddressesFailed(error);
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const NO_RESPONDER_ADDRS = 'No responder addresses available for formation';
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Parse the responder addresses, skipping any entry that does not parse: the list comes from
|
|
832
|
+
* a stranger's invitation, and one bad entry must not fail an otherwise good list. Entries
|
|
833
|
+
* naming different peers are left for libp2p to refuse; {@link dialFormationByMachine} hands
|
|
834
|
+
* this one machine's addresses at a time.
|
|
835
|
+
*/
|
|
836
|
+
function parseResponderAddrs(addrs: string[]): Multiaddr[] {
|
|
837
|
+
const parsed: Multiaddr[] = [];
|
|
838
|
+
for (const addr of addrs) {
|
|
839
|
+
const ma = parseResponderAddr(addr);
|
|
840
|
+
if (ma) parsed.push(ma);
|
|
841
|
+
}
|
|
842
|
+
if (parsed.length === 0) {
|
|
843
|
+
throw new FormationUnreachableError(NO_RESPONDER_ADDRS);
|
|
844
|
+
}
|
|
845
|
+
return parsed;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function parseResponderAddr(addr: string): Multiaddr | null {
|
|
849
|
+
try {
|
|
850
|
+
return multiaddr(addr);
|
|
851
|
+
} catch (error) {
|
|
852
|
+
log('responderAddrs: skipping unparsable entry: %o', error);
|
|
853
|
+
return null;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/** One machine of the inviting party, and the invitation's addresses for it. */
|
|
858
|
+
interface ResponderMachine {
|
|
859
|
+
peerId: string;
|
|
860
|
+
addrs: string[];
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Split the responder addresses by the machine each reaches (its trailing peer id), keeping
|
|
865
|
+
* the order in which each machine first appears. An entry that does not parse, or names no
|
|
866
|
+
* peer, is dropped: every address an inviter mints (`getMultiaddrs()`, `resolvePeerAddrs`)
|
|
867
|
+
* names its machine, and libp2p refuses one dial that mixes named and unnamed addresses.
|
|
868
|
+
*/
|
|
869
|
+
function groupResponderAddrsByMachine(addrs: string[]): ResponderMachine[] {
|
|
870
|
+
const machines = new Map<string, string[]>();
|
|
871
|
+
for (const addr of addrs) {
|
|
872
|
+
const ma = parseResponderAddr(addr);
|
|
873
|
+
if (!ma) continue;
|
|
874
|
+
const peerId = trailingPeerId(ma);
|
|
875
|
+
if (!peerId) {
|
|
876
|
+
log('responderAddrs: skipping %s — it names no machine', addr);
|
|
877
|
+
continue;
|
|
878
|
+
}
|
|
879
|
+
const group = machines.get(peerId);
|
|
880
|
+
if (group) group.push(addr);
|
|
881
|
+
else machines.set(peerId, [addr]);
|
|
882
|
+
}
|
|
883
|
+
return [...machines].map(([peerId, group]) => ({ peerId, addrs: group }));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Worth asking the next machine of the inviting party: it could not be reached, or it answered
|
|
888
|
+
* with a refusal a sibling may not repeat (the invitation not replicated to it yet, the host
|
|
889
|
+
* strand not running there, …). A final refusal is the party's answer, not one machine's.
|
|
890
|
+
*/
|
|
891
|
+
function shouldTryNextMachine(error: unknown): error is FormationUnreachableError | FormationRejectedError {
|
|
892
|
+
return error instanceof FormationUnreachableError || (error instanceof FormationRejectedError && error.retryable);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Every machine failed without a refusal. A single machine's error already names each address
|
|
897
|
+
* it tried, so it is thrown as is; several are restated as one error naming each machine.
|
|
898
|
+
*/
|
|
899
|
+
function noMachineReached(failures: Array<{ peerId: string; error: FormationUnreachableError }>): FormationUnreachableError {
|
|
900
|
+
if (failures.length === 1) return failures[0].error;
|
|
901
|
+
const errors = failures.map((failure) => failure.error);
|
|
902
|
+
return new FormationUnreachableError(
|
|
903
|
+
`Formation could not reach any of the inviter's ${failures.length} machines: ` +
|
|
904
|
+
failures.map((failure) => `${failure.peerId}: ${failure.error.message}`).join('; '),
|
|
905
|
+
{ cause: new AggregateError(errors, 'Every inviting machine was unreachable') }
|
|
906
|
+
);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* libp2p reports a dial in which every address failed as `AggregateError('All multiaddr dials
|
|
911
|
+
* failed')`, which names none of them; restate it with each address's own error, which names
|
|
912
|
+
* its host and port. A dial that tried only one address already surfaces that address's own
|
|
913
|
+
* error, so anything else passes through unchanged.
|
|
914
|
+
*/
|
|
915
|
+
function describeAllAddressesFailed(error: unknown): unknown {
|
|
916
|
+
if (!(error instanceof AggregateError)) return error;
|
|
917
|
+
const reasons = error.errors.map((reason: unknown) => reason instanceof Error ? reason.message : String(reason));
|
|
918
|
+
return new FormationUnreachableError(
|
|
919
|
+
`Formation could not reach the inviter at any of the ${reasons.length} addresses tried: ${reasons.join('; ')}`,
|
|
920
|
+
{ cause: error }
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/** Report a failure that came before any answer as {@link FormationUnreachableError}, keeping its message. */
|
|
925
|
+
function asUnreachable(error: unknown): FormationUnreachableError {
|
|
926
|
+
if (error instanceof FormationUnreachableError) return error;
|
|
927
|
+
return new FormationUnreachableError(error instanceof Error ? error.message : String(error), { cause: error });
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* Open the formation stream, send the contact, and read back the one result frame, closing
|
|
932
|
+
* the stream either way. Nothing here has an answer from the responder yet, so the caller
|
|
933
|
+
* reports any failure as {@link FormationUnreachableError} — a frame too large or not JSON
|
|
934
|
+
* included, since it is not an answer either.
|
|
935
|
+
*/
|
|
936
|
+
async function exchangeContact(
|
|
937
|
+
node: Libp2p,
|
|
938
|
+
addrs: Multiaddr[],
|
|
939
|
+
contact: FormationContactMessage,
|
|
940
|
+
budgets: { protocolId: string; dialTimeoutMs: number; awaitResponseMs: number }
|
|
941
|
+
): Promise<FormationResultMessage> {
|
|
942
|
+
const stream = await openFormationStream(node, addrs, budgets.protocolId, budgets.dialTimeoutMs);
|
|
943
|
+
try {
|
|
944
|
+
const reader = new FrameReader(stream);
|
|
945
|
+
writeFrame(stream, contact);
|
|
946
|
+
return await withTimeout(budgets.awaitResponseMs, 'Formation await-response', () => reader.read<FormationResultMessage>());
|
|
947
|
+
} finally {
|
|
948
|
+
try { await stream.close(); } catch { /* ignore */ }
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Turn the responder's answer into the dial's result. A refusal throws
|
|
954
|
+
* {@link FormationRejectedError}; an approval that fails validation throws a plain `Error`,
|
|
955
|
+
* since that is a misbehaving responder rather than a refusal.
|
|
956
|
+
*/
|
|
957
|
+
async function acceptResult(
|
|
958
|
+
response: FormationResultMessage,
|
|
959
|
+
validateResponse: FormationDialOptions['validateResponse']
|
|
960
|
+
): Promise<FormationDialResult> {
|
|
961
|
+
if (!response.approved) {
|
|
962
|
+
throw new FormationRejectedError(response.code, response.reason);
|
|
963
|
+
}
|
|
964
|
+
if (!(await validateResponse(response))) throw new Error('Responder result failed validation');
|
|
965
|
+
|
|
966
|
+
if (!response.provisionResult) throw new Error('Missing provision result for responderCreates mode');
|
|
967
|
+
// Sanitized HERE — the wire boundary — so nothing above this call ever handles an
|
|
968
|
+
// unvalidated, unbounded address list from a remote peer.
|
|
969
|
+
return { provision: response.provisionResult, strandAddrs: sanitizeStrandAddrs(response.strandAddrs) };
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Initiator side of the native formation protocol. Dials the responder, sends the
|
|
974
|
+
* contact (carrying the real disclosure/token/cadre), validates the responder's
|
|
975
|
+
* result, and returns the strand the responder provisioned plus the responder's
|
|
976
|
+
* strand-network addresses (see {@link FormationDialResult}).
|
|
977
|
+
*
|
|
978
|
+
* Throws {@link FormationUnreachableError} for every failure before a result frame is read
|
|
979
|
+
* (including the session deadline passing first), {@link FormationRejectedError} when the
|
|
980
|
+
* responder refuses, and a plain `Error` for an approval that fails validation.
|
|
981
|
+
*/
|
|
982
|
+
export async function dialFormation(node: Libp2p, options: FormationDialOptions): Promise<FormationDialResult> {
|
|
983
|
+
const addrs = parseResponderAddrs(options.responderAddrs);
|
|
984
|
+
const protocolId = options.protocolId ?? FORMATION_PROTOCOL;
|
|
985
|
+
const deadlines = formationDeadlines(options.linkRoundTripMs);
|
|
986
|
+
const sessionTimeoutMs = options.sessionTimeoutMs ?? deadlines.sessionMs;
|
|
987
|
+
const dialTimeoutMs = options.dialTimeoutMs ?? deadlines.dialMs;
|
|
988
|
+
const awaitResponseMs = resolveProvisionTimeoutMs(
|
|
989
|
+
options.provisionTimeoutMs, deadlines.initiatorAwaitResponseMs,
|
|
990
|
+
sessionTimeoutMs, dialTimeoutMs, 'dialFormation'
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
let answered = false;
|
|
994
|
+
try {
|
|
995
|
+
return await withTimeout(sessionTimeoutMs, 'Formation dial', async () => {
|
|
996
|
+
const response = await exchangeContact(node, addrs, options.contact, { protocolId, dialTimeoutMs, awaitResponseMs });
|
|
997
|
+
answered = true;
|
|
998
|
+
return await acceptResult(response, options.validateResponse);
|
|
999
|
+
});
|
|
1000
|
+
} catch (error) {
|
|
1001
|
+
throw answered ? error : asUnreachable(error);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Initiator side for a whole invitation: one {@link dialFormation} session per machine of the
|
|
1007
|
+
* inviting party the bootstrap list names (see {@link FormationDialOptions.responderAddrs}), in
|
|
1008
|
+
* order, until one approves.
|
|
1009
|
+
*
|
|
1010
|
+
* A machine that cannot be reached, or that refuses retryably, hands over to the next. A final
|
|
1011
|
+
* refusal, or an approval that fails validation, is thrown at once: another machine of the
|
|
1012
|
+
* same party would answer the same, and an approval may already have spent the invitation.
|
|
1013
|
+
* With no machine left, throws the last retryable refusal when any machine answered (the party
|
|
1014
|
+
* is reachable but not ready, which says more than "unreachable"), otherwise one
|
|
1015
|
+
* {@link FormationUnreachableError} naming each machine's failure.
|
|
1016
|
+
*
|
|
1017
|
+
* Every session sends the SAME contact. `FormationUsage` is keyed by the contact's
|
|
1018
|
+
* `usageStampId`, so at most one machine of the inviting party can record this redemption,
|
|
1019
|
+
* even when one the joiner gave up on was still committing it.
|
|
1020
|
+
*/
|
|
1021
|
+
export async function dialFormationByMachine(node: Libp2p, options: FormationDialOptions): Promise<FormationDialResult> {
|
|
1022
|
+
const machines = groupResponderAddrsByMachine(options.responderAddrs);
|
|
1023
|
+
if (machines.length === 0) {
|
|
1024
|
+
throw new FormationUnreachableError(NO_RESPONDER_ADDRS);
|
|
1025
|
+
}
|
|
1026
|
+
const unreached: Array<{ peerId: string; error: FormationUnreachableError }> = [];
|
|
1027
|
+
let refusal: FormationRejectedError | undefined;
|
|
1028
|
+
for (const machine of machines) {
|
|
1029
|
+
try {
|
|
1030
|
+
return await dialFormation(node, { ...options, responderAddrs: machine.addrs });
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
if (!shouldTryNextMachine(error)) throw error;
|
|
1033
|
+
log('formation via %s failed (%s), trying the next inviting machine', machine.peerId, error.message);
|
|
1034
|
+
if (error instanceof FormationRejectedError) refusal = error;
|
|
1035
|
+
else unreached.push({ peerId: machine.peerId, error });
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
throw refusal ?? noMachineReached(unreached);
|
|
1039
|
+
}
|