@serfab/cadre-core 0.13.0 → 1.0.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 +20 -6
- package/dist/bootstrap-peer-store-file.js +2 -2
- package/dist/bootstrap-peer-store.d.ts +32 -12
- package/dist/bootstrap-peer-store.js +41 -15
- package/dist/bootstrap-peer-store.js.map +1 -1
- package/dist/cadre-node.d.ts +558 -80
- package/dist/cadre-node.js +1060 -170
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +13 -26
- package/dist/control-cohort.js +0 -26
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +299 -44
- package/dist/control-database.js +469 -69
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +40 -11
- package/dist/control-read-retry.js +48 -15
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-retry.d.ts +58 -3
- package/dist/control-retry.js +58 -3
- package/dist/control-retry.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +103 -19
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +80 -11
- package/dist/control-write-retry.js +168 -29
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store.js +2 -2
- package/dist/index.d.ts +14 -7
- package/dist/index.js +33 -6
- package/dist/index.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +15 -7
- package/dist/node-local-snapshot.js +23 -8
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +143 -0
- package/dist/peer-dial.js +176 -0
- package/dist/peer-dial.js.map +1 -0
- package/dist/relay-addrs.d.ts +90 -52
- package/dist/relay-addrs.js +91 -59
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +71 -3
- package/dist/relay-reservation.js +116 -8
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +30 -1
- package/dist/seed-bootstrap.js +62 -17
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/storage-scope.d.ts +62 -0
- package/dist/storage-scope.js +75 -0
- package/dist/storage-scope.js.map +1 -0
- package/dist/strand-database.d.ts +34 -16
- package/dist/strand-database.js +38 -24
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +152 -0
- package/dist/strand-first-sync-gate.js +208 -0
- package/dist/strand-first-sync-gate.js.map +1 -0
- package/dist/strand-formation-manager.d.ts +87 -5
- package/dist/strand-formation-manager.js +109 -5
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +21 -1
- package/dist/strand-formation-protocol.js +30 -0
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +243 -6
- package/dist/strand-instance-manager.js +470 -37
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +20 -12
- package/dist/strand-member-key.js +20 -12
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.js +3 -0
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +361 -0
- package/dist/strand-membership-reconciler.js +548 -0
- package/dist/strand-membership-reconciler.js.map +1 -0
- package/dist/strand-membership-writer.d.ts +131 -11
- package/dist/strand-membership-writer.js +396 -134
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +46 -14
- package/dist/strand-network-config.js +79 -23
- package/dist/strand-network-config.js.map +1 -1
- package/dist/strand-solicitation.d.ts +10 -1
- package/dist/strand-solicitation.js +5 -0
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +11 -7
- package/dist/strand-wake-protocol.js +20 -52
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.js +6 -0
- package/dist/strand-watcher.js.map +1 -1
- package/dist/timed-step.d.ts +12 -0
- package/dist/timed-step.js +28 -0
- package/dist/timed-step.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +19 -0
- package/dist/timeout-scheduler.js +22 -0
- package/dist/timeout-scheduler.js.map +1 -0
- package/dist/types.d.ts +262 -30
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/bootstrap-peer-store-file.ts +2 -2
- package/src/bootstrap-peer-store.ts +69 -27
- package/src/cadre-node.ts +7301 -6353
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +122 -130
- package/src/control-database.ts +537 -69
- package/src/control-read-retry.ts +202 -167
- package/src/control-retry.ts +297 -190
- package/src/control-schema.ts +103 -19
- package/src/control-write-retry.ts +525 -383
- package/src/enrolled-machine-store.ts +2 -2
- package/src/index.ts +83 -6
- package/src/node-local-snapshot.ts +25 -8
- package/src/peer-dial.ts +215 -0
- package/src/relay-addrs.ts +458 -436
- package/src/relay-reservation.ts +951 -828
- package/src/seed-bootstrap.ts +76 -18
- package/src/storage-scope.ts +78 -0
- package/src/strand-database.ts +50 -27
- package/src/strand-first-sync-gate.ts +258 -0
- package/src/strand-formation-manager.ts +140 -5
- package/src/strand-formation-protocol.ts +43 -1
- package/src/strand-instance-manager.ts +577 -37
- package/src/strand-member-key.ts +20 -12
- package/src/strand-member-registry.ts +3 -0
- package/src/strand-membership-reconciler.ts +680 -0
- package/src/strand-membership-writer.ts +527 -189
- package/src/strand-network-config.ts +102 -24
- package/src/strand-solicitation.ts +16 -1
- package/src/strand-wake-protocol.ts +21 -66
- package/src/strand-watcher.ts +6 -0
- package/src/timed-step.ts +28 -0
- package/src/timeout-scheduler.ts +28 -0
- package/src/types.ts +1789 -1554
|
@@ -4,7 +4,8 @@ import type { Libp2p } from '@libp2p/interface';
|
|
|
4
4
|
import type {
|
|
5
5
|
OpenInvitation,
|
|
6
6
|
FormStrandResult,
|
|
7
|
-
StrandFormationDisclosure
|
|
7
|
+
StrandFormationDisclosure,
|
|
8
|
+
StrandMembershipInvite
|
|
8
9
|
} from './types.js';
|
|
9
10
|
import {
|
|
10
11
|
FormationApprovalError,
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
} from './formation-approval.js';
|
|
13
14
|
// control-database does not import this manager, so this import introduces no cycle.
|
|
14
15
|
import { FormationAbortedError, InvitationExhaustedError } from './control-database.js';
|
|
16
|
+
import { PreSplitStrandIdentityError } from './strand-membership-writer.js';
|
|
15
17
|
import { canonicalJson } from './canonical-json.js';
|
|
16
18
|
import type {
|
|
17
19
|
DisclosureValidator,
|
|
@@ -26,6 +28,7 @@ import {
|
|
|
26
28
|
PROVISION_RESPONSE_TRAVEL_MARGIN_MS,
|
|
27
29
|
dialFormation,
|
|
28
30
|
isValidResponderCreatesResult,
|
|
31
|
+
isWellFormedMembershipInvite,
|
|
29
32
|
type FormationContactMessage,
|
|
30
33
|
type FormationProvisionResult,
|
|
31
34
|
type FormationResultMessage,
|
|
@@ -42,6 +45,39 @@ const log = debug('sereus:cadre:formation-manager');
|
|
|
42
45
|
*/
|
|
43
46
|
const MAX_DISCLOSURE_BYTES = 8 * 1024;
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* How long a formation-issued strand membership invitation stays redeemable (7 days).
|
|
50
|
+
* The responder's {@link StrandFormationManagerOptions.issueMembershipInvite} hook stamps
|
|
51
|
+
* `now + this` as the `Strand.Invite.Expiration`. Long enough for a slow joiner to bring
|
|
52
|
+
* its strand node up and redeem; short enough that a lost formation result does not leave
|
|
53
|
+
* a live bearer credential in the strand forever. An expired invitation rolls the
|
|
54
|
+
* redemption back cleanly (the schema's `NotExpired` gate) — the joiner's recovery is a
|
|
55
|
+
* fresh formation, which issues a fresh invitation.
|
|
56
|
+
*/
|
|
57
|
+
export const MEMBERSHIP_INVITE_TTL_MS = 7 * 24 * 3600_000;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Rejection reason for a bound CLOSED-strand redemption whose responder could not issue
|
|
61
|
+
* the joiner's membership invitation — the host strand runtime not live on this
|
|
62
|
+
* responder, no party identity key yet, or the strand DB refusing the `Strand.Invite`
|
|
63
|
+
* write. Retryable on purpose, and rejected BEFORE the consent row is recorded, so the
|
|
64
|
+
* formation token stays unspent: approving without an invitation would admit a joiner
|
|
65
|
+
* that looks joined but can never become a member, and a responder not running the
|
|
66
|
+
* strand cannot serve the joiner's sync anyway. The one permanent failure,
|
|
67
|
+
* a pre-split host strand, gets {@link HOST_STRAND_MUST_BE_RECREATED_REASON} instead.
|
|
68
|
+
*/
|
|
69
|
+
export const MEMBERSHIP_INVITE_UNAVAILABLE_REASON = 'Strand membership invitation unavailable, retry';
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Rejection reason for a bound CLOSED-strand redemption whose host strand was founded
|
|
73
|
+
* before the per-party identity split: the responder's founder launch refused it
|
|
74
|
+
* (`PreSplitStrandIdentityError`), so no invitation can ever be issued into it. Not
|
|
75
|
+
* retryable — the host must recreate the strand and issue a fresh invitation. Like
|
|
76
|
+
* {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON}, rejected before consent is recorded, so
|
|
77
|
+
* the formation token stays unspent.
|
|
78
|
+
*/
|
|
79
|
+
export const HOST_STRAND_MUST_BE_RECREATED_REASON = 'Host strand must be recreated';
|
|
80
|
+
|
|
45
81
|
/**
|
|
46
82
|
* Rejection reason a would-be joiner is told for each approval-failure category
|
|
47
83
|
* (see {@link FormationApprovalFailure}). Distinct per category on purpose: mapping any
|
|
@@ -107,6 +143,27 @@ export interface StrandFormationManagerOptions {
|
|
|
107
143
|
* addresses and cross-party joiners fall back to an empty seed.
|
|
108
144
|
*/
|
|
109
145
|
resolveStrandAddrs?: (strandId: string) => string[];
|
|
146
|
+
/**
|
|
147
|
+
* Issue a single-use `Strand.Invite` against the live host strand a BOUND redemption
|
|
148
|
+
* resolved to (responder side) — the seam that gets the JOINER its own membership.
|
|
149
|
+
* Like {@link resolveStrandAddrs}, the formation layer cannot reach the strand
|
|
150
|
+
* runtime itself, so `CadreNode` wires this to its own running-instance +
|
|
151
|
+
* `StrandPartyKey` lookup.
|
|
152
|
+
*
|
|
153
|
+
* Contract: return the issued invitation for a CLOSED host strand; return `null`
|
|
154
|
+
* for an open one (no members, nothing to invite into); THROW when a closed strand's
|
|
155
|
+
* invitation cannot be issued (runtime not live, no party key, strand DB write
|
|
156
|
+
* rejected) — the manager then rejects the whole redemption with
|
|
157
|
+
* {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON} BEFORE recording consent, so the
|
|
158
|
+
* formation token stays unspent and the joiner can retry. Throw a
|
|
159
|
+
* `PreSplitStrandIdentityError` for a host strand that can never issue one; that maps
|
|
160
|
+
* to the non-retryable {@link HOST_STRAND_MUST_BE_RECREATED_REASON}.
|
|
161
|
+
*
|
|
162
|
+
* Left unwired — mock/transport tests — the bound path approves with no invitation,
|
|
163
|
+
* mirroring the unwired {@link resolveStrandAddrs} posture. Production
|
|
164
|
+
* (`CadreNode.initializeStrandSolicitation`) always wires it.
|
|
165
|
+
*/
|
|
166
|
+
issueMembershipInvite?: (strandId: string) => Promise<StrandMembershipInvite | null>;
|
|
110
167
|
/** Configuration options */
|
|
111
168
|
config?: StrandFormationManagerConfig;
|
|
112
169
|
}
|
|
@@ -132,6 +189,7 @@ export class StrandFormationManager {
|
|
|
132
189
|
private readonly partyId: string;
|
|
133
190
|
private readonly cadrePeerAddrs: string[];
|
|
134
191
|
private readonly resolveStrandAddrs?: (strandId: string) => string[];
|
|
192
|
+
private readonly issueMembershipInvite?: (strandId: string) => Promise<StrandMembershipInvite | null>;
|
|
135
193
|
private readonly config: StrandFormationManagerConfig;
|
|
136
194
|
private readonly listener: FormationListener;
|
|
137
195
|
private readonly registeredNodes = new Set<Libp2p>();
|
|
@@ -145,6 +203,7 @@ export class StrandFormationManager {
|
|
|
145
203
|
this.partyId = options.partyId;
|
|
146
204
|
this.cadrePeerAddrs = options.cadrePeerAddrs ?? [];
|
|
147
205
|
this.resolveStrandAddrs = options.resolveStrandAddrs;
|
|
206
|
+
this.issueMembershipInvite = options.issueMembershipInvite;
|
|
148
207
|
this.config = options.config ?? {};
|
|
149
208
|
|
|
150
209
|
this.listener = new FormationListener({
|
|
@@ -241,6 +300,10 @@ export class StrandFormationManager {
|
|
|
241
300
|
// Undefined for an open strand. Kept separate from invitePrivateKey (the initiator's
|
|
242
301
|
// generated signing key), which is set by the StrandSolicitationService layer.
|
|
243
302
|
memberPrivateKey: provision.memberPrivateKey,
|
|
303
|
+
// Carried only when well-formed. The DEFAULT response validator already rejects
|
|
304
|
+
// a malformed one; this re-check keeps a custom validator that skips the shape
|
|
305
|
+
// check from feeding a hostile responder's object into the joiner's invite cache.
|
|
306
|
+
membershipInvite: this.membershipInviteFrom(provision),
|
|
244
307
|
// Possibly empty — the responder may hold no live strand node yet.
|
|
245
308
|
strandAddrs: dialed.strandAddrs
|
|
246
309
|
};
|
|
@@ -256,6 +319,23 @@ export class StrandFormationManager {
|
|
|
256
319
|
return { listeners: this.listener.activeCount, dialers: this.dialerSessions };
|
|
257
320
|
}
|
|
258
321
|
|
|
322
|
+
/**
|
|
323
|
+
* The provision result's membership invitation, iff well-formed — a malformed one
|
|
324
|
+
* (possible only past a custom {@link FormationResponseValidator} that skipped the
|
|
325
|
+
* structural check) is dropped with a log rather than cached: it could never redeem
|
|
326
|
+
* anyway, and carrying an unbounded hostile object outlives the session.
|
|
327
|
+
*/
|
|
328
|
+
private membershipInviteFrom(provision: FormationProvisionResult): StrandMembershipInvite | undefined {
|
|
329
|
+
if (provision.membershipInvite === undefined) {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
if (!isWellFormedMembershipInvite(provision.membershipInvite)) {
|
|
333
|
+
log('formStrand: dropping malformed membershipInvite from responder for strand %s', provision.strand.strandId);
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
return { inviteKey: provision.membershipInvite.inviteKey, invitePrivateKey: provision.membershipInvite.invitePrivateKey };
|
|
337
|
+
}
|
|
338
|
+
|
|
259
339
|
/**
|
|
260
340
|
* Derive the initiator's await-response budget from the configured RESPONDER budget —
|
|
261
341
|
* never the same number (see `StrandFormationManagerConfig.provisionTimeoutMs`). Mirrors
|
|
@@ -306,10 +386,14 @@ export class StrandFormationManager {
|
|
|
306
386
|
* {@link ResponderProvisionOutcome} with `approved: false` — `runSession` turns that
|
|
307
387
|
* into a clean, non-disclosing reply instead of a dropped result frame.
|
|
308
388
|
*
|
|
309
|
-
* - **bound** (host strand present): provision-then-record —
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
389
|
+
* - **bound** (host strand present): provision-then-record — first issue the joiner's
|
|
390
|
+
* single-use strand membership invitation via the wired `issueMembershipInvite` seam
|
|
391
|
+
* ({@link issueBoundMembershipInvite}; a closed strand whose invitation cannot be
|
|
392
|
+
* issued rejects retryably HERE, before any consent row spends the token), then write
|
|
393
|
+
* the single `FormationUsage` consent row against the pre-existing strand
|
|
394
|
+
* (record-only) and return it + its membership key + the invitation (all read-gating
|
|
395
|
+
* secrets disclosed only here, behind the token + disclosure validation `runSession`
|
|
396
|
+
* already enforced).
|
|
313
397
|
* - **missing** (invite names a host strand absent on this responder, e.g. unconverged):
|
|
314
398
|
* reject cleanly + retryably, writing NO usage row — recording usage here would fail the
|
|
315
399
|
* deferred `StrandExists` CHECK at commit and drop the frame.
|
|
@@ -350,6 +434,16 @@ export class StrandFormationManager {
|
|
|
350
434
|
try {
|
|
351
435
|
switch (resolved.kind) {
|
|
352
436
|
case 'bound': {
|
|
437
|
+
// Issued BEFORE the consent row is recorded, so an issue failure rejects with
|
|
438
|
+
// the formation token still unspent (retryable). The inverse window — invite
|
|
439
|
+
// issued, then recordUsage fails/aborts — orphans a live `Strand.Invite` no
|
|
440
|
+
// joiner ever received; bounded deliberately by its expiry
|
|
441
|
+
// ({@link MEMBERSHIP_INVITE_TTL_MS}) rather than compensated, since nothing
|
|
442
|
+
// here can atomically un-issue a strand-DB row.
|
|
443
|
+
const issued = await this.issueBoundMembershipInvite(token, resolved.strandId);
|
|
444
|
+
if (!issued.ok) {
|
|
445
|
+
return { approved: false, reason: issued.reason };
|
|
446
|
+
}
|
|
353
447
|
// recorder is guaranteed non-null here: only resolveStrand can yield 'bound'.
|
|
354
448
|
await recorder!.recordUsage({
|
|
355
449
|
token,
|
|
@@ -363,6 +457,9 @@ export class StrandFormationManager {
|
|
|
363
457
|
return this.approve({
|
|
364
458
|
strand: { strandId: resolved.strandId, createdBy: 'responder' },
|
|
365
459
|
memberPrivateKey: resolved.memberPrivateKey ?? undefined,
|
|
460
|
+
// Omitted (not sent undefined-valued) so rejection-parity and open-strand
|
|
461
|
+
// assertions stay plain `toBeUndefined()` on the parsed frame.
|
|
462
|
+
...(issued.invite ? { membershipInvite: issued.invite } : {}),
|
|
366
463
|
dbConnectionInfo: { endpoint: 'local', credentialsRef: '' }
|
|
367
464
|
});
|
|
368
465
|
}
|
|
@@ -407,6 +504,44 @@ export class StrandFormationManager {
|
|
|
407
504
|
}
|
|
408
505
|
}
|
|
409
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Issue the joiner's membership invitation for a BOUND redemption via the wired
|
|
509
|
+
* {@link StrandFormationManagerOptions.issueMembershipInvite} seam.
|
|
510
|
+
*
|
|
511
|
+
* - Hook unwired (mock/transport tests): approve with no invitation, the same posture
|
|
512
|
+
* as an unwired `resolveStrandAddrs`.
|
|
513
|
+
* - Hook returns an invitation (closed host strand): carry it on the approval.
|
|
514
|
+
* - Hook returns `null` (open host strand): approve with no invitation.
|
|
515
|
+
* - Hook throws (runtime not live, no party key, strand-DB write rejected): report
|
|
516
|
+
* `ok: false` with {@link MEMBERSHIP_INVITE_UNAVAILABLE_REASON} — the caller rejects
|
|
517
|
+
* BEFORE any consent row is written, so the formation token stays unspent.
|
|
518
|
+
* - Hook throws `PreSplitStrandIdentityError` (the host strand was founded before the
|
|
519
|
+
* per-party identity split and can never issue one): same, but with the
|
|
520
|
+
* non-retryable {@link HOST_STRAND_MUST_BE_RECREATED_REASON}.
|
|
521
|
+
*
|
|
522
|
+
* The LOG-before-reject keeps both a deliberate internal-error→protocol-rejection
|
|
523
|
+
* conversion, same as the catch-all in {@link provisionAsResponder}.
|
|
524
|
+
*/
|
|
525
|
+
private async issueBoundMembershipInvite(
|
|
526
|
+
token: string,
|
|
527
|
+
strandId: string
|
|
528
|
+
): Promise<{ ok: true; invite?: StrandMembershipInvite } | { ok: false; reason: string }> {
|
|
529
|
+
if (!this.issueMembershipInvite) {
|
|
530
|
+
return { ok: true };
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
const invite = await this.issueMembershipInvite(strandId);
|
|
534
|
+
return { ok: true, invite: invite ?? undefined };
|
|
535
|
+
} catch (err) {
|
|
536
|
+
if (err instanceof PreSplitStrandIdentityError) {
|
|
537
|
+
log('host strand %s is pre-split (token %s); rejecting — it must be recreated: %o', strandId, token, err);
|
|
538
|
+
return { ok: false, reason: HOST_STRAND_MUST_BE_RECREATED_REASON };
|
|
539
|
+
}
|
|
540
|
+
log('membership-invite issue for strand %s failed (token %s); rejecting retryably: %o', strandId, token, err);
|
|
541
|
+
return { ok: false, reason: MEMBERSHIP_INVITE_UNAVAILABLE_REASON };
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
410
545
|
/**
|
|
411
546
|
* Responder-provisions fallback (invite binds no host strand). Precedence:
|
|
412
547
|
*
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import debug from 'debug';
|
|
22
22
|
import { multiaddr } from '@multiformats/multiaddr';
|
|
23
23
|
import type { Libp2p } from '@libp2p/interface';
|
|
24
|
-
import type { StrandFormationDisclosure } from './types.js';
|
|
24
|
+
import type { StrandFormationDisclosure, StrandMembershipInvite } from './types.js';
|
|
25
25
|
import { type ControlStream, writeFrame, withTimeout } from './control-stream.js';
|
|
26
26
|
import { canonicalJson } from './canonical-json.js';
|
|
27
27
|
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
@@ -147,6 +147,16 @@ export interface FormationProvisionResult {
|
|
|
147
147
|
* already-used token discloses neither identity nor key. Absent for open strands.
|
|
148
148
|
*/
|
|
149
149
|
memberPrivateKey?: string;
|
|
150
|
+
/**
|
|
151
|
+
* The joiner's single-use membership invitation into the (closed, bound) host
|
|
152
|
+
* strand — issued by the responder's live strand runtime against `Strand.Invite`
|
|
153
|
+
* just before the consent row was recorded. Same disclosure timing as
|
|
154
|
+
* {@link memberPrivateKey}: present only on an approved result, absent for open
|
|
155
|
+
* strands and the responder-provisions path. Bounded and shape-checked on the
|
|
156
|
+
* initiator side ({@link isWellFormedMembershipInvite}) — a hostile responder's
|
|
157
|
+
* malformed field fails validation rather than crashing the initiator.
|
|
158
|
+
*/
|
|
159
|
+
membershipInvite?: StrandMembershipInvite;
|
|
150
160
|
dbConnectionInfo: FormationDbConnectionInfo;
|
|
151
161
|
}
|
|
152
162
|
|
|
@@ -313,6 +323,32 @@ class FrameReader {
|
|
|
313
323
|
/** Matches the placeholder cadre addrs (`cadre-a-1.local`, …) of the removed strand-proto transport. */
|
|
314
324
|
const PLACEHOLDER_CADRE_ADDR = /^cadre-[ab]-\d+\.local$/;
|
|
315
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Both halves of a {@link StrandMembershipInvite} are base64url ed25519 key encodings —
|
|
328
|
+
* 43 chars for 32 bytes. The bound is deliberately loose (a future encoding change must
|
|
329
|
+
* not silently strand joiners) while still capping what a hostile responder can make the
|
|
330
|
+
* initiator retain.
|
|
331
|
+
*/
|
|
332
|
+
const MAX_MEMBERSHIP_INVITE_KEY_LENGTH = 256;
|
|
333
|
+
const BASE64URL_KEY = /^[A-Za-z0-9_-]+$/;
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Is `value` a well-formed {@link StrandMembershipInvite} — exactly two bounded
|
|
337
|
+
* base64url strings? The initiator-side shape check for the one attacker-influenced
|
|
338
|
+
* OBJECT the formation result carries (`strandAddrs` gets the equivalent treatment in
|
|
339
|
+
* {@link sanitizeStrandAddrs}): a malformed field from a hostile responder must fail
|
|
340
|
+
* validation, never crash the initiator or be carried into the joiner's invite cache.
|
|
341
|
+
* `undefined` is NOT well-formed — absence is legal on the result and callers gate on
|
|
342
|
+
* presence first.
|
|
343
|
+
*/
|
|
344
|
+
export function isWellFormedMembershipInvite(value: unknown): value is StrandMembershipInvite {
|
|
345
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
346
|
+
const { inviteKey, invitePrivateKey } = value as Record<string, unknown>;
|
|
347
|
+
const wellFormedKey = (key: unknown): boolean =>
|
|
348
|
+
typeof key === 'string' && key.length > 0 && key.length <= MAX_MEMBERSHIP_INVITE_KEY_LENGTH && BASE64URL_KEY.test(key);
|
|
349
|
+
return wellFormedKey(inviteKey) && wellFormedKey(invitePrivateKey);
|
|
350
|
+
}
|
|
351
|
+
|
|
316
352
|
/**
|
|
317
353
|
* Structural check on a `responderCreates` result: the responder must have approved,
|
|
318
354
|
* disclosed a non-empty real identity + cadre (not the legacy `cadre-*.local`
|
|
@@ -335,6 +371,12 @@ export function isValidResponderCreatesResult(response: FormationResultMessage):
|
|
|
335
371
|
const provision = response.provisionResult;
|
|
336
372
|
if (!provision?.strand?.strandId) return false;
|
|
337
373
|
if (provision.strand.createdBy !== 'responder') return false;
|
|
374
|
+
// Optional, but when present it must be well-formed: a malformed invitation is a
|
|
375
|
+
// membership the joiner can never redeem, so reject the result rather than accept a
|
|
376
|
+
// formation that would leave a half-member.
|
|
377
|
+
if (provision.membershipInvite !== undefined && !isWellFormedMembershipInvite(provision.membershipInvite)) {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
338
380
|
return true;
|
|
339
381
|
}
|
|
340
382
|
|