@serfab/cadre-core 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/cadre-invite-protocol.d.ts +348 -0
  2. package/dist/cadre-invite-protocol.js +605 -0
  3. package/dist/cadre-invite-protocol.js.map +1 -0
  4. package/dist/cadre-node.d.ts +523 -180
  5. package/dist/cadre-node.js +921 -293
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/claim-proof.d.ts +45 -0
  8. package/dist/claim-proof.js +132 -0
  9. package/dist/claim-proof.js.map +1 -0
  10. package/dist/control-authorization.d.ts +80 -19
  11. package/dist/control-authorization.js +55 -13
  12. package/dist/control-authorization.js.map +1 -1
  13. package/dist/control-cohort.js +1 -1
  14. package/dist/control-cohort.js.map +1 -1
  15. package/dist/control-database.d.ts +514 -97
  16. package/dist/control-database.js +1033 -195
  17. package/dist/control-database.js.map +1 -1
  18. package/dist/control-protocol-guard.d.ts +142 -0
  19. package/dist/control-protocol-guard.js +293 -0
  20. package/dist/control-protocol-guard.js.map +1 -0
  21. package/dist/control-schema.d.ts +1 -1
  22. package/dist/control-schema.js +801 -1067
  23. package/dist/control-schema.js.map +1 -1
  24. package/dist/control-stream.d.ts +4 -0
  25. package/dist/control-stream.js +22 -7
  26. package/dist/control-stream.js.map +1 -1
  27. package/dist/delegate-admission.d.ts +1 -1
  28. package/dist/delegate-admission.js +1 -1
  29. package/dist/device-token.d.ts +11 -9
  30. package/dist/device-token.js +14 -12
  31. package/dist/device-token.js.map +1 -1
  32. package/dist/ed25519-key.d.ts +15 -0
  33. package/dist/ed25519-key.js +28 -1
  34. package/dist/ed25519-key.js.map +1 -1
  35. package/dist/index.d.ts +14 -9
  36. package/dist/index.js +27 -9
  37. package/dist/index.js.map +1 -1
  38. package/dist/link-budget.d.ts +16 -29
  39. package/dist/link-budget.js +16 -31
  40. package/dist/link-budget.js.map +1 -1
  41. package/dist/membership-connection-gater.d.ts +190 -151
  42. package/dist/membership-connection-gater.js +235 -173
  43. package/dist/membership-connection-gater.js.map +1 -1
  44. package/dist/node-claim-payload.d.ts +19 -0
  45. package/dist/node-claim-payload.js +120 -0
  46. package/dist/node-claim-payload.js.map +1 -0
  47. package/dist/owner-anchor-sync.d.ts +22 -0
  48. package/dist/owner-anchor-sync.js +143 -0
  49. package/dist/owner-anchor-sync.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +113 -18
  51. package/dist/peer-authorization.js +225 -29
  52. package/dist/peer-authorization.js.map +1 -1
  53. package/dist/peer-dial.d.ts +19 -0
  54. package/dist/peer-dial.js +22 -1
  55. package/dist/peer-dial.js.map +1 -1
  56. package/dist/peer-join-backfill.d.ts +26 -2
  57. package/dist/peer-join-backfill.js +54 -12
  58. package/dist/peer-join-backfill.js.map +1 -1
  59. package/dist/peer-record.d.ts +13 -9
  60. package/dist/peer-record.js +16 -12
  61. package/dist/peer-record.js.map +1 -1
  62. package/dist/pending-join-runner.d.ts +66 -58
  63. package/dist/pending-join-runner.js +136 -145
  64. package/dist/pending-join-runner.js.map +1 -1
  65. package/dist/push-fanout.d.ts +8 -2
  66. package/dist/push-fanout.js +8 -2
  67. package/dist/push-fanout.js.map +1 -1
  68. package/dist/seed-bootstrap.d.ts +147 -122
  69. package/dist/seed-bootstrap.js +284 -269
  70. package/dist/seed-bootstrap.js.map +1 -1
  71. package/dist/seed-trust-policy.d.ts +98 -37
  72. package/dist/seed-trust-policy.js +188 -2
  73. package/dist/seed-trust-policy.js.map +1 -1
  74. package/dist/strand-addr-protocol.d.ts +24 -26
  75. package/dist/strand-addr-protocol.js +19 -26
  76. package/dist/strand-addr-protocol.js.map +1 -1
  77. package/dist/strand-formation-protocol.js +1 -1
  78. package/dist/strand-formation-protocol.js.map +1 -1
  79. package/dist/strand-revocation-enforcer.d.ts +11 -11
  80. package/dist/strand-revocation-enforcer.js +11 -11
  81. package/dist/strand-wake-protocol.d.ts +13 -16
  82. package/dist/strand-wake-protocol.js +13 -20
  83. package/dist/strand-wake-protocol.js.map +1 -1
  84. package/dist/trusted-owner-store.d.ts +35 -5
  85. package/dist/trusted-owner-store.js +50 -8
  86. package/dist/trusted-owner-store.js.map +1 -1
  87. package/dist/types.d.ts +302 -87
  88. package/dist/types.js.map +1 -1
  89. package/package.json +8 -7
  90. package/src/cadre-invite-protocol.ts +864 -0
  91. package/src/cadre-node.ts +9381 -8686
  92. package/src/claim-proof.ts +147 -0
  93. package/src/control-authorization.ts +203 -117
  94. package/src/control-cohort.ts +1 -1
  95. package/src/control-database.ts +1216 -218
  96. package/src/control-protocol-guard.ts +348 -0
  97. package/src/control-schema.ts +812 -1078
  98. package/src/control-stream.ts +26 -7
  99. package/src/delegate-admission.ts +1 -1
  100. package/src/device-token.ts +14 -10
  101. package/src/ed25519-key.ts +28 -1
  102. package/src/index.ts +84 -10
  103. package/src/link-budget.ts +16 -32
  104. package/src/membership-connection-gater.ts +637 -570
  105. package/src/node-claim-payload.ts +125 -0
  106. package/src/owner-anchor-sync.ts +171 -0
  107. package/src/peer-authorization.ts +258 -29
  108. package/src/peer-dial.ts +22 -1
  109. package/src/peer-join-backfill.ts +56 -11
  110. package/src/peer-record.ts +16 -11
  111. package/src/pending-join-runner.ts +729 -735
  112. package/src/push-fanout.ts +8 -2
  113. package/src/seed-bootstrap.ts +344 -321
  114. package/src/seed-trust-policy.ts +353 -140
  115. package/src/strand-addr-protocol.ts +26 -35
  116. package/src/strand-formation-protocol.ts +1 -1
  117. package/src/strand-revocation-enforcer.ts +11 -11
  118. package/src/strand-wake-protocol.ts +13 -23
  119. package/src/trusted-owner-store.ts +242 -171
  120. package/src/types.ts +2471 -2242
@@ -4,11 +4,13 @@ import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-c
4
4
  import { multiaddr } from '@multiformats/multiaddr';
5
5
  import { peerIdFromString } from '@libp2p/peer-id';
6
6
  import { withDeadline, exchangeFrame, readStreamToEnd, replyAndClose } from './control-stream.js';
7
- import { dialPeerAddrs, SelfRelayOnlyError, DEFAULT_PEER_DIAL_BUDGET } from './peer-dial.js';
7
+ import { dialPeerAddrs, SelfRelayOnlyError, PeerUnreachableError, DEFAULT_PEER_DIAL_BUDGET, } from './peer-dial.js';
8
+ import { withTrailingPeerId } from './peer-record.js';
8
9
  import { relayedRequestBudgetMs } from './link-budget.js';
9
10
  import { generateStampId } from './control-database.js';
10
11
  import { canonicalJson } from './canonical-json.js';
11
12
  import { deviceTokenAddDigest } from './peer-authorization.js';
13
+ import { ed25519PublicKeyB64FromPeerId } from './ed25519-key.js';
12
14
  import { anchoredTrustPolicy, } from './seed-trust-policy.js';
13
15
  const log = debug('sereus:cadre:seed-bootstrap');
14
16
  /** Protocol ID for seed delivery */
@@ -48,28 +50,6 @@ export function decodeLengthPrefixedFrame(data, maxLength = MAX_SEED_SIZE) {
48
50
  }
49
51
  return data.subarray(4, 4 + length);
50
52
  }
51
- /**
52
- * Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
53
- *
54
- * An Ed25519 PeerId is an identity multihash of the public key, so
55
- * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
56
- * base64url form matches the `OwnerKey.Key` representation (and
57
- * `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
58
- * id, a missing embedded key, or any parse failure — callers treat null as
59
- * "not an owner" rather than throwing.
60
- */
61
- export function ed25519PublicKeyB64FromPeerId(peerId) {
62
- try {
63
- const parsed = peerIdFromString(peerId);
64
- if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
65
- return null;
66
- }
67
- return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
68
- }
69
- catch {
70
- return null;
71
- }
72
- }
73
53
  /**
74
54
  * Canonical byte representation of the authenticated seed fields.
75
55
  *
@@ -82,12 +62,28 @@ export function ed25519PublicKeyB64FromPeerId(peerId) {
82
62
  export function canonicalSeedPayload(seed) {
83
63
  return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
84
64
  }
65
+ /**
66
+ * The digest a seed's signature covers: sha256 over {@link canonicalSeedPayload},
67
+ * base64url. One function so the signer (`createSeed`), the verifier
68
+ * (`validateSeedSignature`) and the claim proof (`claim-proof.ts`, which binds a
69
+ * proof to one seed through this value) all name the same bytes.
70
+ */
71
+ export function seedDigest(seed) {
72
+ return digest([canonicalSeedPayload(seed)], 'sha256', 'base64url');
73
+ }
85
74
  /**
86
75
  * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
87
76
  * so the dial counters read zero rather than being absent.
88
77
  */
89
- function seedRejected(error) {
90
- return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
78
+ function seedRejected(error, code) {
79
+ return {
80
+ success: false,
81
+ peersAdded: 0,
82
+ error,
83
+ ...(code !== undefined ? { code } : {}),
84
+ ownerDialsAttempted: 0,
85
+ ownerDialsFailed: 0,
86
+ };
91
87
  }
92
88
  /**
93
89
  * Parse each address, dropping (and logging) any that is malformed, so one bad
@@ -105,6 +101,55 @@ function parseDialAddrs(addrs) {
105
101
  }
106
102
  return parsed;
107
103
  }
104
+ /**
105
+ * Merge a peer list's addresses into `node`'s peer store, so the node can dial them: the
106
+ * seed's peers after its signature and trust checks, the dial hints a member returns on
107
+ * a cadre invitation redemption (`cadre-invite-protocol.ts`), or the node a seed is being
108
+ * delivered to ({@link SeedBootstrapService.deliverSeed}). Best-effort per peer — one
109
+ * unparsable entry costs that peer, not the rest — and a peer with no address is skipped,
110
+ * since there is nothing to dial. Returns how many peers were merged.
111
+ */
112
+ export async function mergeSeedPeers(node, peers) {
113
+ let peersAdded = 0;
114
+ for (const peer of peers) {
115
+ if (peer.multiaddrs.length === 0) {
116
+ continue;
117
+ }
118
+ try {
119
+ const peerId = peerIdFromString(peer.peerId);
120
+ const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
121
+ await node.peerStore.merge(peerId, { multiaddrs: addrs });
122
+ peersAdded++;
123
+ log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
124
+ }
125
+ catch (error) {
126
+ log('Failed to add peer %s: %o', peer.peerId, error);
127
+ }
128
+ }
129
+ return peersAdded;
130
+ }
131
+ /**
132
+ * A delivery target's addresses, each bound to its peer id (`withTrailingPeerId`) so the dial
133
+ * authenticates the node it aims at rather than trusting whoever answers. An address that does
134
+ * not parse, or that names another peer, throws: that is the caller's mistake, not the node
135
+ * being unreachable.
136
+ */
137
+ function deliveryDialAddrs(peerId, addrs) {
138
+ return addrs.map((text) => {
139
+ const bound = withTrailingPeerId(multiaddr(text), peerId);
140
+ if (!bound) {
141
+ throw new Error(`Seed delivery to ${peerId}: address ${text} names another peer`);
142
+ }
143
+ return bound;
144
+ });
145
+ }
146
+ /**
147
+ * A connection to `peerId` that `node` already holds and a seed stream can use: open and without
148
+ * relay limits, the rule libp2p applies when `dialProtocol` reuses a connection.
149
+ */
150
+ function openUnlimitedConnection(node, peerId) {
151
+ return node.getConnections(peerId).find((c) => c.status === 'open' && c.limits === undefined);
152
+ }
108
153
  /**
109
154
  * SeedBootstrapService handles control network seed generation and delivery.
110
155
  *
@@ -158,10 +203,10 @@ export class SeedBootstrapService {
158
203
  * `registerHandler` (default true) gates registration of the shared inbound
159
204
  * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
160
205
  * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
161
- * it on. The throwaway temp services CadreNode builds in `applySeed` /
162
- * `dialInvite` pass `false`: they only need the stored `libp2pNode` /
163
- * `controlDatabase` for dialing and known-key lookup, and must NOT bind a
164
- * discarded closure to the shared node (a handler leak, and a second
206
+ * it on. The throwaway temp service CadreNode builds in `applySeed` passes
207
+ * `false`: it only needs the stored `libp2pNode` / `controlDatabase` for
208
+ * dialing and known-key lookup, and must NOT bind a discarded closure to the
209
+ * shared node (a handler leak, and a second
165
210
  * `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
166
211
  *
167
212
  * Rejects when libp2p refuses the registration; the node and database are
@@ -268,7 +313,7 @@ export class SeedBootstrapService {
268
313
  */
269
314
  async insertSelfDeviceToken(record) {
270
315
  const stampId = generateStampId(record.peerId);
271
- const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
316
+ const signature = this.signDigest(deviceTokenAddDigest(this.config.partyId, { ...record, stampId }));
272
317
  if (!this.controlDatabase) {
273
318
  throw new Error('Control database not initialized');
274
319
  }
@@ -363,7 +408,7 @@ export class SeedBootstrapService {
363
408
  *
364
409
  * The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
365
410
  * signature over the DISTINCT 'remove'-tagged digest
366
- * `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
411
+ * `digest('CadreControl.CadrePeer', 'remove', party_id(), old.PeerId, old.StampId)` by an owner
367
412
  * key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
368
413
  * never be replayed to delete.
369
414
  *
@@ -406,6 +451,37 @@ export class SeedBootstrapService {
406
451
  await this.controlDatabase.deleteCadrePeer(peerId, ownerKey, message => this.signMessageBytes(message));
407
452
  log('Peer %s removed successfully (stamp retired)', peerId);
408
453
  }
454
+ /**
455
+ * Seat `ownerPublicKey` as a further owner, signed by this service's owner key. The
456
+ * liveness check, the stamp, the signature and the membership notify are
457
+ * {@link ControlDatabase.insertOwnerKeyVouched}'s; what stays here is the owner-key
458
+ * precondition, checked before any read (see {@link removePeer}).
459
+ *
460
+ * @returns `true` when the row was seated, `false` when the key is already a live owner.
461
+ */
462
+ async addOwner(ownerPublicKey) {
463
+ const ownerKey = this.requireOwnerPublicKey();
464
+ if (!this.controlDatabase) {
465
+ throw new Error('Control database not initialized');
466
+ }
467
+ return await this.controlDatabase.insertOwnerKeyVouched(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
468
+ }
469
+ /**
470
+ * Remove `ownerPublicKey` from the owners, signed by this service's owner key: the delete
471
+ * and its `Revocation` tombstone in one transaction. The by-name refusals (self-removal,
472
+ * the last owner), the digests and the membership notify are
473
+ * {@link ControlDatabase.deleteOwnerKey}'s; what stays here is the owner-key precondition,
474
+ * checked before any read (see {@link removePeer}).
475
+ *
476
+ * @returns `true` when the row was removed, `false` when the key is not a live owner here.
477
+ */
478
+ async removeOwner(ownerPublicKey) {
479
+ const ownerKey = this.requireOwnerPublicKey();
480
+ if (!this.controlDatabase) {
481
+ throw new Error('Control database not initialized');
482
+ }
483
+ return await this.controlDatabase.deleteOwnerKey(ownerPublicKey, ownerKey, message => this.signMessageBytes(message));
484
+ }
409
485
  /**
410
486
  * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
411
487
  * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
@@ -483,6 +559,40 @@ export class SeedBootstrapService {
483
559
  }
484
560
  return this.controlDatabase.openRevocationLedger(ownerKey, message => this.signMessageBytes(message));
485
561
  }
562
+ /**
563
+ * Owner-signed INSERT of a `CadreInvite` row — an invitation any member machine can redeem
564
+ * on this owner's behalf ({@link ControlDatabase.insertCadreInvite}). The keypair mint, the
565
+ * expiry and use-count defaults, and the bundle the holder receives are
566
+ * `CadreNode.createCadreInvitation`'s; what stays here is the owner-key precondition and
567
+ * the signature, as {@link insertSelfDeviceToken} keeps them for device tokens.
568
+ *
569
+ * @returns the row as stored, which the invitation bundle carries verbatim.
570
+ * @throws if no owner private key is configured or the control database is not initialized.
571
+ */
572
+ async insertCadreInvite(invite) {
573
+ // Fail fast on a keyless service before any DB work (see removePeer).
574
+ const ownerKey = this.requireOwnerPublicKey();
575
+ if (!this.controlDatabase) {
576
+ throw new Error('Control database not initialized');
577
+ }
578
+ return await this.controlDatabase.insertCadreInvite(invite, ownerKey, message => this.signMessageBytes(message));
579
+ }
580
+ /**
581
+ * Owner-signed WITHDRAWAL of a `CadreInvite`: the `Revocation` tombstone over its stamp, with
582
+ * the row kept ({@link ControlDatabase.withdrawCadreInvite}). What stays here is the
583
+ * owner-key precondition.
584
+ *
585
+ * @returns `true` when this call filed the tombstone, `false` when the row is absent here or
586
+ * already withdrawn.
587
+ * @throws if no owner private key is configured or the control database is not initialized.
588
+ */
589
+ async withdrawCadreInvite(key) {
590
+ const ownerKey = this.requireOwnerPublicKey();
591
+ if (!this.controlDatabase) {
592
+ throw new Error('Control database not initialized');
593
+ }
594
+ return await this.controlDatabase.withdrawCadreInvite(key, ownerKey, message => this.signMessageBytes(message));
595
+ }
486
596
  /**
487
597
  * Create a seed from the current control network state.
488
598
  * The seed contains peer information and is signed by an owner.
@@ -503,9 +613,7 @@ export class SeedBootstrapService {
503
613
  peers,
504
614
  };
505
615
  // Sign the seed over its canonical byte representation
506
- const seedJson = canonicalSeedPayload(seedData);
507
- const seedDigest = digest([seedJson], 'sha256', 'base64url');
508
- const signature = sign(seedDigest, this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
616
+ const signature = sign(seedDigest(seedData), this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
509
617
  const seed = {
510
618
  ...seedData,
511
619
  signature,
@@ -527,11 +635,11 @@ export class SeedBootstrapService {
527
635
  *
528
636
  * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
529
637
  * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
530
- * anchored without re-supplying the invite.
638
+ * anchored without re-supplying the pin.
531
639
  *
532
640
  * @param seed - The seed to apply (already transport-decoded).
533
641
  * @param options.trustPolicy - Per-call policy override (e.g. a
534
- * `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
642
+ * `pinnedKeyTrustPolicy` built from an operator pin) used instead of the
535
643
  * service-configured default for this seed only.
536
644
  */
537
645
  async applySeed(seed, options) {
@@ -550,15 +658,20 @@ export class SeedBootstrapService {
550
658
  if (!this.libp2pNode) {
551
659
  return seedRejected('Service not initialized');
552
660
  }
553
- // NOTE: `seed.partyId` is never checked against `config.partyId`. Nothing
554
- // downstream reads it — trust is keyed on `signerKey` vs the anchor, and the
555
- // anchor a stray-party seed could write into belongs to THIS party, which
556
- // only a caller-supplied pin for that signer can reach. If applying a seed
557
- // ever branches on its partyId (or the anchor becomes multi-party), reject a
661
+ // NOTE: `seed.partyId` is never checked against `config.partyId`. Trust is keyed on
662
+ // `signerKey` vs the anchor, and the anchor a stray-party seed could write into
663
+ // belongs to THIS party, which only a caller-supplied pin for that signer can reach.
664
+ // The one reader of the seed's party is the claim policy, which records it as the
665
+ // party the node now serves (`CadreNodeConfig.claim.record`): a mismatch is still not
666
+ // rejected there because an unclaimed node's own party is a placeholder, and the
667
+ // claim seed is the first thing to name the real one. If any other path ever
668
+ // branches on the seed's partyId (or the anchor becomes multi-party), reject a
558
669
  // mismatch here instead.
559
670
  log('Applying seed for party: %s', seed.partyId);
560
- // Validate the seed signature
561
- if (!this.validateSeedSignature(seed)) {
671
+ // One digest serves the signature check and the trust context: a claim proof is
672
+ // bound to the seed through the same value the signature covers.
673
+ const digestB64 = seedDigest(seed);
674
+ if (!this.validateSeedSignature(seed, digestB64)) {
562
675
  return seedRejected('Invalid seed signature');
563
676
  }
564
677
  // Evaluate the trust anchor for the signer key. The known-owner set comes
@@ -573,30 +686,19 @@ export class SeedBootstrapService {
573
686
  partyId: seed.partyId,
574
687
  signerKey: seed.signerKey,
575
688
  knownOwnerKeys,
689
+ // Optional-chained like `dialSeedOwners`: partial libp2p handles (unit-test doubles)
690
+ // omit `peerId`. A real node always has one, and a proof bound to a real peer id can
691
+ // only fail against '' — the safe direction.
692
+ localPeerId: this.libp2pNode.peerId?.toString() ?? '',
693
+ seedDigest: digestB64,
694
+ claimProof: options?.claimProof,
695
+ remotePeerId: options?.remotePeerId,
576
696
  });
577
697
  if (!decision.trusted) {
578
- return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
698
+ return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy', decision.code);
579
699
  }
580
700
  await this.anchorAcceptedSigner(seed.signerKey, decision);
581
- let peersAdded = 0;
582
- // Add peers to the peer store
583
- for (const peer of seed.peers) {
584
- try {
585
- // Import peer multiaddrs into the peer store
586
- if (peer.multiaddrs.length > 0) {
587
- const peerId = peerIdFromString(peer.peerId);
588
- const addrs = peer.multiaddrs.map(ma => multiaddr(ma));
589
- await this.libp2pNode.peerStore.merge(peerId, {
590
- multiaddrs: addrs
591
- });
592
- peersAdded++;
593
- log('Added peer to store: %s with %d addrs', peer.peerId, addrs.length);
594
- }
595
- }
596
- catch (error) {
597
- log('Failed to add peer %s: %o', peer.peerId, error);
598
- }
599
- }
701
+ const peersAdded = await mergeSeedPeers(this.libp2pNode, seed.peers);
600
702
  log('Merged seed: %d peers added', peersAdded);
601
703
  return { success: true, peersAdded, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
602
704
  }
@@ -657,7 +759,7 @@ export class SeedBootstrapService {
657
759
  }
658
760
  /**
659
761
  * Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
660
- * later seed from the same owner is anchored without re-supplying the invite
762
+ * later seed from the same owner is anchored without re-supplying the pin
661
763
  * or re-prompting. Only the policy decides this happens (`anchorAs` is unset
662
764
  * when the key was already anchored, so a plain re-apply writes nothing) and
663
765
  * `trust()` is idempotent, keeping the original provenance for a known key.
@@ -665,14 +767,19 @@ export class SeedBootstrapService {
665
767
  * Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
666
768
  * in-memory anchor synchronously, so this seed and the rest of the session are
667
769
  * unaffected — only durability across a restart is lost, and that is logged.
770
+ * That holds for pins and TOFU, which are re-supplied at the next start; a claim
771
+ * is the opposite case, so `claimSecretTrustPolicy` anchors its signer itself,
772
+ * awaits durability, and never sets `anchorAs` (the type excludes `'claim'`).
668
773
  *
669
774
  * NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
670
775
  * unauthorized to authorized, which the write-driven membership-gate refresh
671
- * (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. Every
672
- * anchor mutation today rides seed application, and both seed paths refresh the
673
- * gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`). If some
674
- * future path anchors an owner OUTSIDE seed application, it owes the same
675
- * `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
776
+ * (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. The anchor
777
+ * is mutated from two places: here, on seed application, where both seed paths
778
+ * refresh the gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`);
779
+ * and by the anchor sync (`CadreNode.syncOwnerAnchor`), which runs INSIDE the
780
+ * membership refresh, ahead of the membership read, so its changes are judged by
781
+ * the same pass. If some future path anchors an owner outside both, it owes the
782
+ * same `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
676
783
  */
677
784
  async anchorAcceptedSigner(signerKey, decision) {
678
785
  if (!decision.anchorAs || !this.config.trustedOwners) {
@@ -704,43 +811,103 @@ export class SeedBootstrapService {
704
811
  /**
705
812
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
706
813
  *
707
- * Sender hardening: the whole exchange — dial, write, ack read — is bounded by
708
- * {@link seedDeliverTimeoutMs}, and the ack is capped at {@link MAX_SEED_SIZE}.
709
- * The target is a NOT-YET-TRUSTED node the instigator chose to dial during
710
- * onboarding, so an unbounded read here is strictly more exposed than the
711
- * membership-gated receiver paths: without the bound a target that accepts the
712
- * stream and never replies parks this call forever, and one that streams
713
- * arbitrary bytes as a fake ack exhausts memory.
814
+ * `target` is a multiaddr string, dialed as given, or a peer id with its addresses
815
+ * ({@link SeedDeliveryTarget}). For the second, the connection is formed first and on
816
+ * its own limits ({@link connectForDelivery}): an open one is reused, otherwise each
817
+ * address is dialed in turn, so one that never answers cannot use up the time a later
818
+ * one needed. Its addresses are also merged into the peer store, so identify and later
819
+ * dials see them. A peer id with no addresses is dialed by id, which reuses an open
820
+ * connection or tries the peer store's addresses. `options.claimProof` rides beside
821
+ * the seed in the message when the target is a brand-new node being claimed
822
+ * (`claim-proof.ts`).
823
+ *
824
+ * Sender hardening: the request — opening the stream (with its dial, for the forms
825
+ * that dial by `dialProtocol`), write, ack read — is bounded by {@link seedDeliverTimeoutMs},
826
+ * and the ack is capped at {@link MAX_SEED_SIZE}. The target is a NOT-YET-TRUSTED node the
827
+ * instigator chose to dial during onboarding, so an unbounded read here is strictly more
828
+ * exposed than the membership-gated receiver paths: without the bound a target that accepts
829
+ * the stream and never replies parks this call forever, and one that streams arbitrary bytes
830
+ * as a fake ack exhausts memory. A peer id with addresses can therefore take up to
831
+ * `dialBudget.totalMs` plus {@link seedDeliverTimeoutMs} (114.5 s at the default declared
832
+ * link) when none of its addresses answers.
833
+ *
834
+ * @throws {PeerUnreachableError} for a peer id with addresses, when no connection to it
835
+ * formed; nothing was sent. Anything else thrown after the connection formed means the
836
+ * node was reached and the exchange failed.
714
837
  */
715
- async deliverSeed(targetMultiaddr, seed) {
838
+ async deliverSeed(target, seed, options) {
716
839
  if (!this.libp2pNode) {
717
840
  throw new Error('Service not initialized');
718
841
  }
719
- // Capture the node so the closure below needs no non-null assertion.
720
- const node = this.libp2pNode;
721
- const addr = multiaddr(targetMultiaddr);
722
- log('Delivering seed to: %s', targetMultiaddr);
723
- return await withDeadline(this.seedDeliverTimeoutMs, `Seed delivery to ${targetMultiaddr}`, (signal) => this.sendSeed(node, addr, seed, signal));
842
+ const label = typeof target === 'string' ? target : target.peerId;
843
+ log('Delivering seed to: %s', label);
844
+ const openStream = await this.seedStreamOpener(this.libp2pNode, target);
845
+ return await withDeadline(this.seedDeliverTimeoutMs, `Seed delivery to ${label}`, (signal) => this.sendSeed(openStream, seed, options?.claimProof, signal));
846
+ }
847
+ /**
848
+ * The part of {@link deliverSeed} that runs before its deadline: how the delivery's stream
849
+ * will be opened, with any connection it needs already formed.
850
+ */
851
+ async seedStreamOpener(node, target) {
852
+ if (typeof target === 'string') {
853
+ const addr = multiaddr(target);
854
+ return (signal) => node.dialProtocol(addr, SEED_PROTOCOL, { signal });
855
+ }
856
+ const peerId = peerIdFromString(target.peerId);
857
+ const addrs = deliveryDialAddrs(target.peerId, target.multiaddrs);
858
+ await mergeSeedPeers(node, [{ peerId: target.peerId, multiaddrs: target.multiaddrs, isOwner: false }]);
859
+ if (addrs.length === 0) {
860
+ return (signal) => node.dialProtocol(peerId, SEED_PROTOCOL, { signal });
861
+ }
862
+ const connection = await this.connectForDelivery(node, peerId, addrs);
863
+ return (signal) => connection.newStream(SEED_PROTOCOL, { signal });
864
+ }
865
+ /**
866
+ * A connection to `peerId` for {@link deliverSeed}: one already open, or one dialed from
867
+ * `addrs` by {@link dialPeerAddrs} — each address on `dialBudget.perAddressMs`, the whole
868
+ * peer within `dialBudget.totalMs`, direct addresses before relayed ones and the given
869
+ * order otherwise.
870
+ *
871
+ * @throws {PeerUnreachableError} when no connection formed, with the dial's error as `cause`.
872
+ */
873
+ async connectForDelivery(node, peerId, addrs) {
874
+ const open = openUnlimitedConnection(node, peerId);
875
+ if (open) {
876
+ log('Seed delivery to %s reuses an open connection', peerId);
877
+ return open;
878
+ }
879
+ // NOTE: a relayed address can return a limited connection, on which `sendSeed`'s stream
880
+ // open then fails as a plain error rather than `PeerUnreachableError`, though nothing was
881
+ // sent. Claim codes carry no relay addresses today; if a delivery target ever may, count a
882
+ // limited connection as unreachable here.
883
+ try {
884
+ return await dialPeerAddrs(node, addrs, this.dialBudget, `Seed delivery dial of ${peerId}`);
885
+ }
886
+ catch (error) {
887
+ throw new PeerUnreachableError(peerId.toString(), error);
888
+ }
724
889
  }
725
890
  /**
726
891
  * Open one stream to the target, send the seed frame, half-close, and read the ack.
727
892
  *
728
- * `signal` is the deadline from {@link deliverSeed}: it goes to `dialProtocol` so
729
- * a timeout during connect aborts the dial, and into {@link exchangeFrame} so a
730
- * timeout after the stream is open resets it — releasing the otherwise unbounded
893
+ * `signal` is the deadline from {@link deliverSeed}: it goes to `openStream` so a
894
+ * timeout while dialing or opening the stream aborts it, and into {@link exchangeFrame}
895
+ * so a timeout after the stream is open resets it — releasing the otherwise unbounded
731
896
  * ack-read.
732
897
  *
733
898
  * Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
734
- * tiny frame over a relay, whereas a seed is up to 1MB and this delivery path
735
- * does not dial relay addresses today. Changing that is a separate decision.
899
+ * tiny frame over a relay, whereas a seed is up to 1MB, so a relayed address that
900
+ * forms only a limited connection fails here when the stream is opened. Changing
901
+ * that is a separate decision.
736
902
  */
737
- async sendSeed(node, addr, seed, signal) {
738
- const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
903
+ async sendSeed(openStream, seed, claimProof, signal) {
904
+ const rawStream = await openStream(signal);
739
905
  const message = {
740
906
  partyId: seed.partyId,
741
907
  peers: seed.peers,
742
908
  signature: seed.signature,
743
909
  signerKey: seed.signerKey,
910
+ ...(claimProof !== undefined ? { claimProof } : {}),
744
911
  };
745
912
  const ack = await exchangeFrame(rawStream, signal, message, (stream) => this.readSeedAck(stream), 'Seed delivery aborted by timeout');
746
913
  log('Seed delivery response: accepted=%s', ack.accepted);
@@ -777,15 +944,16 @@ export class SeedBootstrapService {
777
944
  }
778
945
  /**
779
946
  * Validate a seed's signature.
947
+ *
948
+ * @param digestB64 - The seed's {@link seedDigest}, when the caller already computed it
949
+ * (`verifyAndMergeSeed` shares one digest between this check and the trust context).
780
950
  */
781
- validateSeedSignature(seed) {
951
+ validateSeedSignature(seed, digestB64 = seedDigest(seed)) {
782
952
  try {
783
- // Reconstruct the signed bytes via the shared canonical payload builder so
784
- // verification is independent of key order. The payload is the fixed
785
- // `{ partyId, peers }` the producer emits.
786
- const seedJson = canonicalSeedPayload(seed);
787
- const seedDigest = digest([seedJson], 'sha256', 'base64url');
788
- return verify(seedDigest, seed.signature, seed.signerKey, 'ed25519', 'base64url', 'base64url', 'base64url');
953
+ // The digest is over the shared canonical payload, so verification is
954
+ // independent of key order; the payload is the fixed `{ partyId, peers }`
955
+ // the producer emits.
956
+ return verify(digestB64, seed.signature, seed.signerKey, 'ed25519', 'base64url', 'base64url', 'base64url');
789
957
  }
790
958
  catch (error) {
791
959
  log('Seed signature validation failed: %o', error);
@@ -793,49 +961,16 @@ export class SeedBootstrapService {
793
961
  }
794
962
  }
795
963
  /**
796
- * Query peers from the control database.
797
- *
798
- * Owner identity is sourced from the `OwnerKey` table, not from the
799
- * transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
800
- * multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
801
- * peer is an owner iff that derived key is in the `OwnerKey` set.
802
- * This makes any owner node markable — not just the local one — and ties
803
- * `isOwner` to the control table rather than to `peerId === self`.
804
- *
805
- * Read through {@link ControlDatabase.queryCadrePeers}, not a raw `CadrePeer`
806
- * select: that reader drops any row whose `StampId` is retired in
807
- * `CadreControl.Revocation`, so a removed member's addresses are never packed
808
- * into a seed and pushed into a joiner's peerstore (`applySeed` adds every
809
- * seed peer's addrs and dials the owner-flagged ones). A revoked peer is off
810
- * the addressable surface everywhere, and this is one of its exits.
811
- *
812
- * NOTE: this is the one owner lookup deliberately left on the REPLICATED
813
- * table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
814
- * — the receiver dials owner-flagged peers first — not a trust decision, and
815
- * the receiver re-derives real trust from its own anchor. So a polluted table
816
- * costs at most a wasted dial, while anchoring here would silently drop
817
- * legitimate co-owners this node never pinned. If `isOwner` ever gates
818
- * anything the receiver TRUSTS, move it to the anchor.
964
+ * The peers a seed carries: the control database's own projection
965
+ * ({@link ControlDatabase.querySeedPeers}, shared with the cadre invitation redemption
966
+ * reply), which reads through the revocation filter and flags owners from the replicated
967
+ * `OwnerKey` table — a dial hint, not a trust decision (the NOTE at `projectSeedPeers`).
819
968
  */
820
969
  async queryPeers() {
821
970
  if (!this.controlDatabase) {
822
971
  return [];
823
972
  }
824
- const ownerKeys = await this.controlDatabase.getOwnerKeys();
825
- const rows = await this.controlDatabase.queryCadrePeers();
826
- return rows.map(({ peerId, multiaddr }) => {
827
- // Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
828
- // unparsable id yields null and is treated as a non-owner rather than
829
- // failing the whole seed creation.
830
- const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
831
- const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
832
- return {
833
- peerId,
834
- multiaddrs: multiaddr ? multiaddr.split(',') : [],
835
- isOwner,
836
- ...(isOwner ? { publicKey: pubKeyB64 } : {}),
837
- };
838
- });
973
+ return await this.controlDatabase.querySeedPeers();
839
974
  }
840
975
  /**
841
976
  * Register the seed protocol handler. The inbound closure just delegates to
@@ -881,11 +1016,11 @@ export class SeedBootstrapService {
881
1016
  this.activeStreams++;
882
1017
  let acked = false;
883
1018
  try {
884
- const seed = await this.readSeedFrame(stream);
1019
+ const { seed, claimProof } = await this.readSeedFrame(stream);
885
1020
  this.eventCallbacks.onSeedReceived?.(seed.partyId, remotePeerId);
886
- const merged = await this.verifyAndMergeSeed(seed);
1021
+ const merged = await this.verifyAndMergeSeed(seed, { claimProof, remotePeerId });
887
1022
  acked = true;
888
- await replyAndClose(stream, { accepted: merged.success, reason: merged.error }, 'Seed');
1023
+ await replyAndClose(stream, { accepted: merged.success, reason: merged.error, code: merged.code }, 'Seed');
889
1024
  if (merged.success) {
890
1025
  await this.dialSeedOwners(seed);
891
1026
  this.eventCallbacks.onSeedApplied?.(seed.partyId, merged.peersAdded, seed);
@@ -907,7 +1042,11 @@ export class SeedBootstrapService {
907
1042
  this.activeStreams--;
908
1043
  }
909
1044
  }
910
- /** Read the inbound seed frame to EOF (bounded and size-capped) and decode it. */
1045
+ /**
1046
+ * Read the inbound seed frame to EOF (bounded and size-capped) and decode it. The
1047
+ * claim proof comes back beside the seed: it is not part of what the owner signed, so
1048
+ * `ControlNetworkSeed` does not carry it.
1049
+ */
911
1050
  async readSeedFrame(stream) {
912
1051
  const data = await readStreamToEnd(stream, {
913
1052
  maxBytes: MAX_SEED_SIZE,
@@ -916,10 +1055,13 @@ export class SeedBootstrapService {
916
1055
  });
917
1056
  const message = JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data)));
918
1057
  return {
919
- partyId: message.partyId,
920
- peers: message.peers,
921
- signature: message.signature,
922
- signerKey: message.signerKey,
1058
+ seed: {
1059
+ partyId: message.partyId,
1060
+ peers: message.peers,
1061
+ signature: message.signature,
1062
+ signerKey: message.signerKey,
1063
+ },
1064
+ claimProof: message.claimProof,
923
1065
  };
924
1066
  }
925
1067
  /**
@@ -967,93 +1109,6 @@ export class SeedBootstrapService {
967
1109
  log('Drone %s added, seed created with %d peers', dronePeerId, seed.peers.length);
968
1110
  return { seed, encodedSeed };
969
1111
  }
970
- /**
971
- * Create an invite for a phone to join the cadre.
972
- *
973
- * Use this when a server (public IP) wants to invite a phone (NAT'd).
974
- * The invite is shared out-of-band (QR code, link, etc.) and contains
975
- * the server's address so the phone can dial in.
976
- *
977
- * @param token - Optional invite token for validation
978
- * @param expiresIn - Optional expiration time in milliseconds
979
- * @returns Invite and encoded invite for sharing
980
- */
981
- async createInvite(token, expiresIn) {
982
- if (!this.libp2pNode) {
983
- throw new Error('Service not initialized');
984
- }
985
- log('Creating invite for phone');
986
- // Get this node's dialable addresses. When an inviteAddressResolver is
987
- // configured (typically by `@serfab/cadre-host`'s NatService), it takes
988
- // priority — it may substitute a DDNS hostname and externally-mapped
989
- // port for the raw LAN multiaddrs libp2p reports.
990
- let ownerAddrs;
991
- if (this.config.inviteAddressResolver) {
992
- try {
993
- ownerAddrs = await this.config.inviteAddressResolver();
994
- }
995
- catch (err) {
996
- log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
997
- ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
998
- }
999
- }
1000
- else {
1001
- ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
1002
- }
1003
- // Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
1004
- // the trusted owner set before applying any seed.
1005
- //
1006
- // Sourced ONLY from this node's own anchor, never from the replicated
1007
- // OwnerKey table: the invitee anchors whatever arrives here
1008
- // (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
1009
- // pollutable table would let a stranger's genesis-inserted key ride an
1010
- // otherwise-legitimate invite straight into the new node's anchor —
1011
- // poisoning the very store this whole trust chain rests on. No anchor wired
1012
- // (a directly-constructed service) means no pins to hand out: an invite
1013
- // without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
1014
- // table-sourced one silently hands it an unanchored key.
1015
- const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
1016
- const now = Date.now();
1017
- const invite = {
1018
- partyId: this.config.partyId,
1019
- ownerAddrs,
1020
- ownerKeys: ownerKeys.length ? ownerKeys : undefined,
1021
- token,
1022
- createdAt: now,
1023
- expiresAt: expiresIn ? now + expiresIn : undefined,
1024
- };
1025
- const encodedInvite = this.encodeInvite(invite);
1026
- log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
1027
- return { invite, encodedInvite };
1028
- }
1029
- /**
1030
- * Accept a phone connection using an invite.
1031
- *
1032
- * Use this when a phone dials in with an invite token. This method:
1033
- * 1. Validates the token if provided
1034
- * 2. Authorizes the phone peer
1035
- *
1036
- * After this, the phone can sync the control database normally.
1037
- *
1038
- * @param options - Phone peer info and invite token
1039
- * @param issuedInvite - The original invite for validation
1040
- */
1041
- async acceptPhone(options, issuedInvite) {
1042
- const { phonePeerId, token } = options;
1043
- log('Accepting phone: %s', phonePeerId);
1044
- // Validate token if invite provided
1045
- if (issuedInvite) {
1046
- if (issuedInvite.token && issuedInvite.token !== token) {
1047
- throw new Error('Invalid invite token');
1048
- }
1049
- if (issuedInvite.expiresAt && Date.now() > issuedInvite.expiresAt) {
1050
- throw new Error('Invite has expired');
1051
- }
1052
- }
1053
- // Authorize the phone peer (no multiaddrs - phone is NAT'd)
1054
- await this.authorizePeer({ peerId: phonePeerId });
1055
- log('Phone %s accepted and authorized', phonePeerId);
1056
- }
1057
1112
  /**
1058
1113
  * Add a phone to the cadre with relay support.
1059
1114
  *
@@ -1084,45 +1139,5 @@ export class SeedBootstrapService {
1084
1139
  log('Phone %s added with relay, seed created', phonePeerId);
1085
1140
  return { seed, encodedSeed };
1086
1141
  }
1087
- /**
1088
- * Encode an invite for out-of-band delivery.
1089
- */
1090
- encodeInvite(invite) {
1091
- const json = JSON.stringify(invite);
1092
- return uint8ArrayToString(new TextEncoder().encode(json), 'base64url');
1093
- }
1094
- /**
1095
- * Decode an invite from base64url encoding.
1096
- */
1097
- decodeInvite(encoded) {
1098
- const bytes = uint8ArrayFromString(encoded, 'base64url');
1099
- const json = new TextDecoder().decode(bytes);
1100
- return JSON.parse(json);
1101
- }
1102
- /**
1103
- * Dial an owner from an invite.
1104
- * Use this on a phone after receiving an invite to connect to the owner.
1105
- *
1106
- * @param invite - The invite received out-of-band
1107
- * @returns Connection to the owner
1108
- */
1109
- async dialInvite(invite) {
1110
- if (!this.libp2pNode) {
1111
- throw new Error('Service not initialized');
1112
- }
1113
- // Check expiration
1114
- if (invite.expiresAt && Date.now() > invite.expiresAt) {
1115
- throw new Error('Invite has expired');
1116
- }
1117
- log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
1118
- const addrs = parseDialAddrs(invite.ownerAddrs);
1119
- if (addrs.length === 0) {
1120
- throw new Error('No owner addresses available');
1121
- }
1122
- // Each address on its own time limit, so one that never answers cannot hold
1123
- // the invitee back from the rest (`dialPeerAddrs`).
1124
- const connection = await dialPeerAddrs(this.libp2pNode, addrs, this.dialBudget, 'Invite owner dial');
1125
- log('Connected to owner at: %s', connection.remoteAddr.toString());
1126
- }
1127
1142
  }
1128
1143
  //# sourceMappingURL=seed-bootstrap.js.map