@serfab/cadre-core 0.6.0 → 0.8.1

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 (166) hide show
  1. package/README.md +9 -8
  2. package/dist/authority-key.d.ts +41 -0
  3. package/dist/authority-key.js +52 -0
  4. package/dist/authority-key.js.map +1 -0
  5. package/dist/cadre-node.d.ts +777 -11
  6. package/dist/cadre-node.js +1912 -82
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/canonical-datetime.d.ts +33 -0
  9. package/dist/canonical-datetime.js +38 -0
  10. package/dist/canonical-datetime.js.map +1 -0
  11. package/dist/canonical-json.d.ts +12 -0
  12. package/dist/canonical-json.js +33 -0
  13. package/dist/canonical-json.js.map +1 -0
  14. package/dist/control-cohort.d.ts +63 -0
  15. package/dist/control-cohort.js +80 -0
  16. package/dist/control-cohort.js.map +1 -0
  17. package/dist/control-database.d.ts +259 -5
  18. package/dist/control-database.js +541 -128
  19. package/dist/control-database.js.map +1 -1
  20. package/dist/control-formation-recorder.d.ts +89 -0
  21. package/dist/control-formation-recorder.js +139 -0
  22. package/dist/control-formation-recorder.js.map +1 -0
  23. package/dist/control-schema.d.ts +12 -0
  24. package/dist/control-schema.js +199 -0
  25. package/dist/control-schema.js.map +1 -0
  26. package/dist/control-stream.d.ts +54 -0
  27. package/dist/control-stream.js +104 -0
  28. package/dist/control-stream.js.map +1 -0
  29. package/dist/device-token.d.ts +50 -0
  30. package/dist/device-token.js +66 -0
  31. package/dist/device-token.js.map +1 -0
  32. package/dist/diagnostics/connection-path.d.ts +118 -0
  33. package/dist/diagnostics/connection-path.js +204 -0
  34. package/dist/diagnostics/connection-path.js.map +1 -0
  35. package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
  36. package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
  37. package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
  38. package/dist/hibernation-manager.d.ts +79 -0
  39. package/dist/hibernation-manager.js +173 -23
  40. package/dist/hibernation-manager.js.map +1 -1
  41. package/dist/index.d.ts +25 -4
  42. package/dist/index.js +59 -4
  43. package/dist/index.js.map +1 -1
  44. package/dist/key-store-file.d.ts +52 -0
  45. package/dist/key-store-file.js +194 -0
  46. package/dist/key-store-file.js.map +1 -0
  47. package/dist/key-store.d.ts +75 -0
  48. package/dist/key-store.js +66 -0
  49. package/dist/key-store.js.map +1 -0
  50. package/dist/peer-authorization.d.ts +26 -0
  51. package/dist/peer-authorization.js +40 -0
  52. package/dist/peer-authorization.js.map +1 -0
  53. package/dist/peer-record.d.ts +89 -0
  54. package/dist/peer-record.js +117 -0
  55. package/dist/peer-record.js.map +1 -0
  56. package/dist/push-credentials.d.ts +34 -0
  57. package/dist/push-credentials.js +81 -0
  58. package/dist/push-credentials.js.map +1 -0
  59. package/dist/push-fanout.d.ts +137 -0
  60. package/dist/push-fanout.js +197 -0
  61. package/dist/push-fanout.js.map +1 -0
  62. package/dist/push-notifier-apns.d.ts +53 -0
  63. package/dist/push-notifier-apns.js +195 -0
  64. package/dist/push-notifier-apns.js.map +1 -0
  65. package/dist/push-notifier-fcm.d.ts +38 -0
  66. package/dist/push-notifier-fcm.js +183 -0
  67. package/dist/push-notifier-fcm.js.map +1 -0
  68. package/dist/push-notifier-shared.d.ts +18 -0
  69. package/dist/push-notifier-shared.js +25 -0
  70. package/dist/push-notifier-shared.js.map +1 -0
  71. package/dist/push-notifier.d.ts +71 -0
  72. package/dist/push-notifier.js +50 -0
  73. package/dist/push-notifier.js.map +1 -0
  74. package/dist/schema-verification.d.ts +11 -2
  75. package/dist/schema-verification.js +15 -4
  76. package/dist/schema-verification.js.map +1 -1
  77. package/dist/seed-bootstrap.d.ts +210 -7
  78. package/dist/seed-bootstrap.js +429 -131
  79. package/dist/seed-bootstrap.js.map +1 -1
  80. package/dist/seed-trust-policy.d.ts +63 -0
  81. package/dist/seed-trust-policy.js +56 -0
  82. package/dist/seed-trust-policy.js.map +1 -0
  83. package/dist/strand-addr-protocol.d.ts +132 -0
  84. package/dist/strand-addr-protocol.js +313 -0
  85. package/dist/strand-addr-protocol.js.map +1 -0
  86. package/dist/strand-cohort.d.ts +54 -0
  87. package/dist/strand-cohort.js +38 -0
  88. package/dist/strand-cohort.js.map +1 -0
  89. package/dist/strand-database.d.ts +54 -6
  90. package/dist/strand-database.js +88 -89
  91. package/dist/strand-database.js.map +1 -1
  92. package/dist/strand-formation-manager.d.ts +79 -19
  93. package/dist/strand-formation-manager.js +196 -104
  94. package/dist/strand-formation-manager.js.map +1 -1
  95. package/dist/strand-formation-protocol.d.ts +162 -0
  96. package/dist/strand-formation-protocol.js +279 -0
  97. package/dist/strand-formation-protocol.js.map +1 -0
  98. package/dist/strand-instance-manager.d.ts +73 -4
  99. package/dist/strand-instance-manager.js +167 -41
  100. package/dist/strand-instance-manager.js.map +1 -1
  101. package/dist/strand-member-key.d.ts +29 -0
  102. package/dist/strand-member-key.js +38 -0
  103. package/dist/strand-member-key.js.map +1 -0
  104. package/dist/strand-member-registry.d.ts +98 -0
  105. package/dist/strand-member-registry.js +129 -0
  106. package/dist/strand-member-registry.js.map +1 -0
  107. package/dist/strand-membership-writer.d.ts +361 -0
  108. package/dist/strand-membership-writer.js +449 -0
  109. package/dist/strand-membership-writer.js.map +1 -0
  110. package/dist/strand-solicitation.d.ts +77 -2
  111. package/dist/strand-solicitation.js +25 -4
  112. package/dist/strand-solicitation.js.map +1 -1
  113. package/dist/strand-wake-payload.d.ts +30 -0
  114. package/dist/strand-wake-payload.js +20 -0
  115. package/dist/strand-wake-payload.js.map +1 -0
  116. package/dist/strand-wake-protocol.d.ts +114 -0
  117. package/dist/strand-wake-protocol.js +257 -0
  118. package/dist/strand-wake-protocol.js.map +1 -0
  119. package/dist/strand-watcher.d.ts +6 -2
  120. package/dist/strand-watcher.js +57 -17
  121. package/dist/strand-watcher.js.map +1 -1
  122. package/dist/types.d.ts +413 -23
  123. package/dist/types.js +22 -5
  124. package/dist/types.js.map +1 -1
  125. package/package.json +14 -8
  126. package/src/authority-key.ts +68 -0
  127. package/src/cadre-node.ts +2937 -929
  128. package/src/canonical-datetime.ts +39 -0
  129. package/src/canonical-json.ts +30 -0
  130. package/src/control-cohort.ts +103 -0
  131. package/src/control-database.ts +931 -383
  132. package/src/control-formation-recorder.ts +151 -0
  133. package/src/control-schema.ts +198 -0
  134. package/src/control-stream.ts +128 -0
  135. package/src/device-token.ts +89 -0
  136. package/src/diagnostics/connection-path.ts +277 -0
  137. package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
  138. package/src/hibernation-manager.ts +197 -28
  139. package/src/index.ts +273 -79
  140. package/src/key-store-file.ts +203 -0
  141. package/src/key-store.ts +99 -0
  142. package/src/peer-authorization.ts +53 -0
  143. package/src/peer-record.ts +146 -0
  144. package/src/push-credentials.ts +84 -0
  145. package/src/push-fanout.ts +272 -0
  146. package/src/push-notifier-apns.ts +243 -0
  147. package/src/push-notifier-fcm.ts +221 -0
  148. package/src/push-notifier-shared.ts +28 -0
  149. package/src/push-notifier.ts +93 -0
  150. package/src/schema-verification.ts +18 -4
  151. package/src/seed-bootstrap.ts +504 -171
  152. package/src/seed-trust-policy.ts +107 -0
  153. package/src/strand-addr-protocol.ts +410 -0
  154. package/src/strand-cohort.ts +77 -0
  155. package/src/strand-database.ts +113 -126
  156. package/src/strand-formation-manager.ts +233 -142
  157. package/src/strand-formation-protocol.ts +391 -0
  158. package/src/strand-instance-manager.ts +207 -56
  159. package/src/strand-member-key.ts +39 -0
  160. package/src/strand-member-registry.ts +177 -0
  161. package/src/strand-membership-writer.ts +640 -0
  162. package/src/strand-solicitation.ts +93 -4
  163. package/src/strand-wake-payload.ts +32 -0
  164. package/src/strand-wake-protocol.ts +331 -0
  165. package/src/strand-watcher.ts +61 -17
  166. package/src/types.ts +1012 -575
@@ -1,20 +1,10 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
3
  import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
- import type { Libp2p, PeerId, Connection } from '@libp2p/interface';
4
+ import type { Libp2p, Connection } from '@libp2p/interface';
5
5
  import { multiaddr } from '@multiformats/multiaddr';
6
- import type { Multiaddr } from '@multiformats/multiaddr';
7
6
  import { peerIdFromString } from '@libp2p/peer-id';
8
-
9
- /**
10
- * Minimal libp2p stream surface compatible with libp2p 3.x.
11
- * In libp2p 3.x, streams are AsyncIterable for reading and use send() for writing.
12
- */
13
- interface LibP2PStream extends AsyncIterable<Uint8Array> {
14
- send(data: Uint8Array): boolean;
15
- close(): Promise<void>;
16
- abort(err: Error): void;
17
- }
7
+ import { type ControlStream, writeFrame, readStreamToEnd } from './control-stream.js';
18
8
  import type {
19
9
  ControlNetworkSeed,
20
10
  SeedPeer,
@@ -22,14 +12,21 @@ import type {
22
12
  SeedAckMessage,
23
13
  AuthorizePeerOptions,
24
14
  ApplySeedResult,
25
- SignedTransaction,
26
15
  AddDroneOptions,
27
16
  AddPhoneOptions,
28
17
  DroneInitResult,
29
18
  InviteResult,
30
- CadreInvite
19
+ CadreInvite,
20
+ PeerAddressRecord,
21
+ DeviceTokenRecord
31
22
  } from './types.js';
32
23
  import type { ControlDatabase } from './control-database.js';
24
+ import { canonicalJson } from './canonical-json.js';
25
+ import { peerAuthorizationDigest } from './peer-authorization.js';
26
+ import {
27
+ type SeedTrustPolicy,
28
+ dbAnchoredTrustPolicy,
29
+ } from './seed-trust-policy.js';
33
30
 
34
31
  const log = debug('sereus:cadre:seed-bootstrap');
35
32
 
@@ -39,6 +36,75 @@ export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
39
36
  /** Maximum seed message size (1MB) */
40
37
  const MAX_SEED_SIZE = 1024 * 1024;
41
38
 
39
+ /** Default time the receiver waits for an inbound seed frame before aborting (ms). */
40
+ const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
41
+
42
+ /** Default cap on concurrent inbound seed streams a single peer can pin open. */
43
+ const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
44
+
45
+ /**
46
+ * Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
47
+ *
48
+ * Guards every parse site against malformed input: a buffer too short to hold
49
+ * the prefix, a declared length exceeding `maxLength`, and a declared length
50
+ * exceeding the bytes actually present. Returns a view (`subarray`, no copy) —
51
+ * the body is handed straight to `TextDecoder`.
52
+ */
53
+ export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED_SIZE): Uint8Array {
54
+ if (data.length < 4) {
55
+ throw new Error(`Seed frame too short: ${data.length} bytes, need ≥4 for length prefix`);
56
+ }
57
+ // Pass the full (buffer, byteOffset, byteLength) triple so the read is correct
58
+ // even for a non-zero-offset view, not just the fresh zero-offset arrays
59
+ // current callers pass.
60
+ const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0, false);
61
+ const available = data.length - 4;
62
+ if (length > maxLength) {
63
+ throw new Error(`Seed frame declares length ${length} exceeding max ${maxLength}`);
64
+ }
65
+ if (length > available) {
66
+ throw new Error(`Seed frame declares length ${length} but only ${available} body bytes present`);
67
+ }
68
+ return data.subarray(4, 4 + length);
69
+ }
70
+
71
+ /**
72
+ * Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
73
+ *
74
+ * An Ed25519 PeerId is an identity multihash of the public key, so
75
+ * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
76
+ * base64url form matches the `AuthorityKey.Key` representation (and
77
+ * `authorityKeyFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
78
+ * id, a missing embedded key, or any parse failure — callers treat null as
79
+ * "not an authority" rather than throwing.
80
+ */
81
+ export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
82
+ try {
83
+ const parsed = peerIdFromString(peerId);
84
+ if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
85
+ return null;
86
+ }
87
+ return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Canonical byte representation of the authenticated seed fields.
95
+ *
96
+ * Routes both the creator (`createSeed`) and the verifier
97
+ * (`validateSeedSignature`) through one builder so the signed bytes are
98
+ * identical regardless of key insertion order. `canonicalJson` sorts keys and
99
+ * drops `undefined`, so the signed payload is exactly `{ partyId, peers }` —
100
+ * the fields the producer actually emits.
101
+ */
102
+ export function canonicalSeedPayload(
103
+ seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>
104
+ ): string {
105
+ return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
106
+ }
107
+
42
108
  /**
43
109
  * Configuration for the SeedBootstrapService
44
110
  */
@@ -49,6 +115,37 @@ export interface SeedBootstrapConfig {
49
115
  authorityPrivateKey?: string;
50
116
  /** Authority public key (base64url) - derived from private key if not provided */
51
117
  authorityPublicKey?: string;
118
+ /**
119
+ * Optional async resolver returning the multiaddrs to embed in invites.
120
+ * When unset, `libp2pNode.getMultiaddrs()` is used. Hosts behind NAT supply
121
+ * this (via `@serfab/cadre-host`'s NatService) to substitute the host's
122
+ * DDNS hostname and externally-mapped port.
123
+ */
124
+ inviteAddressResolver?: () => Promise<string[]>;
125
+ /**
126
+ * Trust anchor for incoming seeds. Decides whether a signature-verified
127
+ * `signerKey` should be trusted, against the receiver's known authority
128
+ * keys (NOT the seed body). Defaults to `dbAnchoredTrustPolicy()`, which
129
+ * rejects any signer not already in the `AuthorityKey` table. An enrollment
130
+ * caller can pass a per-seed override to `applySeed` instead.
131
+ *
132
+ * A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
133
+ * — that is the only seam the inbound libp2p seed-protocol handler can use,
134
+ * since a network-delivered seed has no per-call override.
135
+ */
136
+ trustPolicy?: SeedTrustPolicy;
137
+ /**
138
+ * Time the inbound seed handler waits for the seed frame before aborting the
139
+ * read (ms). Defaults to {@link DEFAULT_SEED_READ_TIMEOUT_MS}. Bounds a
140
+ * buggy/compromised own-cadre node that opens a stream and never half-closes.
141
+ */
142
+ seedReadTimeoutMs?: number;
143
+ /**
144
+ * Cap on concurrent inbound seed streams (defaults to
145
+ * {@link DEFAULT_MAX_CONCURRENT_SEEDS}). Over the cap, a non-accepting ack is
146
+ * returned without applying any seed.
147
+ */
148
+ maxConcurrentSeeds?: number;
52
149
  }
53
150
 
54
151
  /**
@@ -68,17 +165,25 @@ export interface SeedEventCallbacks {
68
165
  *
69
166
  * Seeds solve the cold-start problem: new nodes need control data to validate
70
167
  * connections, but can't get data without connecting first. Seeds pre-populate
71
- * the new node's cache with peer information and optionally transactions.
168
+ * the new node's cache with peer information.
72
169
  */
73
170
  export class SeedBootstrapService {
74
171
  private readonly config: SeedBootstrapConfig;
75
172
  private libp2pNode: Libp2p | null = null;
76
173
  private controlDatabase: ControlDatabase | null = null;
77
174
  private readonly authorityPublicKey: string | null;
175
+ private readonly trustPolicy: SeedTrustPolicy;
176
+ private readonly seedReadTimeoutMs: number;
177
+ private readonly maxConcurrentSeeds: number;
178
+ /** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
179
+ private activeStreams = 0;
78
180
  private eventCallbacks: SeedEventCallbacks = {};
79
181
 
80
182
  constructor(config: SeedBootstrapConfig) {
81
183
  this.config = config;
184
+ this.trustPolicy = config.trustPolicy ?? dbAnchoredTrustPolicy();
185
+ this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
186
+ this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
82
187
 
83
188
  // Derive public key from private key if not provided
84
189
  if (config.authorityPrivateKey && !config.authorityPublicKey) {
@@ -104,58 +209,247 @@ export class SeedBootstrapService {
104
209
  }
105
210
 
106
211
  /**
107
- * Initialize the service with libp2p node and control database
212
+ * Whether this service holds an authority private key, i.e. can produce the
213
+ * authority signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
214
+ * and re-authorizations. A seed-listener-only service (`enableSeedListener`,
215
+ * no authority key) returns false: it can receive/apply seeds but cannot author
216
+ * or re-issue authority writes. Used by the write-while-alone re-replication
217
+ * drain to skip authority work on a non-authority node.
108
218
  */
109
- initialize(libp2pNode: Libp2p, controlDatabase: ControlDatabase): void {
219
+ canAuthorize(): boolean {
220
+ return !!this.config.authorityPrivateKey;
221
+ }
222
+
223
+ /**
224
+ * Initialize the service with libp2p node and control database.
225
+ *
226
+ * `registerHandler` (default true) gates registration of the shared inbound
227
+ * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
228
+ * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
229
+ * it on. The throwaway temp services CadreNode builds in `applySeed` /
230
+ * `dialInvite` pass `false`: they only need the stored `libp2pNode` /
231
+ * `controlDatabase` for dialing and known-key lookup, and must NOT bind a
232
+ * discarded closure to the shared node (a handler leak, and a second
233
+ * `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
234
+ */
235
+ initialize(
236
+ libp2pNode: Libp2p,
237
+ controlDatabase: ControlDatabase,
238
+ options?: { registerHandler?: boolean }
239
+ ): void {
110
240
  this.libp2pNode = libp2pNode;
111
241
  this.controlDatabase = controlDatabase;
112
-
113
- // Register the seed protocol handler
114
- this.registerProtocolHandler();
115
-
242
+
243
+ // Register the seed protocol handler unless the caller opted out (temp services).
244
+ if (options?.registerHandler ?? true) {
245
+ this.registerProtocolHandler();
246
+ }
247
+
116
248
  log('SeedBootstrapService initialized');
117
249
  }
118
250
 
119
251
  /**
120
252
  * Authorize a new peer to join the cadre.
121
253
  * Signs the peer ID with the authority key and inserts into CadrePeer table.
254
+ *
255
+ * The authority vouches the `PublicKey <-> PeerId` binding: rather than trust a
256
+ * caller-supplied key, the binding is enforced by construction — `PublicKey` is
257
+ * DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
258
+ * `PublicKey`, and such a row can never be self-updated (it has no key to
259
+ * verify against), which is correct. The row is inserted with a fresh
260
+ * `UpdatedAt` but no self-signature (`Sig` null) — the authority cannot produce
261
+ * the peer's self-signature, so the peer must self-publish (see
262
+ * {@link CadreNode.registerSelf}) before the row resolves.
122
263
  */
123
264
  async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
124
265
  const { peerId, multiaddrs } = options;
125
-
126
- if (!this.config.authorityPrivateKey) {
127
- throw new Error('Authority private key required to authorize peers');
266
+ log('Authorizing peer: %s', peerId);
267
+ // CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
268
+ const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
269
+ await this.insertCadrePeerRow({
270
+ peerId,
271
+ publicKey: ed25519PublicKeyB64FromPeerId(peerId),
272
+ multiaddr: multiaddrStr,
273
+ updatedAt: Date.now(),
274
+ sig: null,
275
+ });
276
+ log('Peer %s authorized successfully', peerId);
277
+ }
278
+
279
+ /**
280
+ * Authority-signed INSERT of this node's OWN self-signed address record.
281
+ *
282
+ * Used by {@link CadreNode.registerSelf} when the node is not yet a member and
283
+ * is its own authority (it holds the authority key): the row is authority-signed
284
+ * (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
285
+ * immediately without a follow-up self-update.
286
+ */
287
+ async insertSelfPeerRecord(record: PeerAddressRecord): Promise<void> {
288
+ await this.insertCadrePeerRow({
289
+ peerId: record.peerId,
290
+ publicKey: record.publicKey,
291
+ multiaddr: record.addrs.join(','),
292
+ updatedAt: record.updatedAt,
293
+ sig: record.sig,
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Shared authority-signed `CadrePeer` INSERT. Signs `digest(peerId)` with the
299
+ * authority key (satisfying `AuthorizedInsert`) and writes the full record
300
+ * row. The authority signature does NOT cover the address columns — those are
301
+ * vouched only as far as the authority asserts them, and a peer's own `Sig`
302
+ * (when present) is what makes the row resolvable.
303
+ */
304
+ private async insertCadrePeerRow(row: {
305
+ peerId: string;
306
+ publicKey: string | null;
307
+ multiaddr: string;
308
+ updatedAt: number;
309
+ sig: string | null;
310
+ }): Promise<void> {
311
+ const signature = this.signPeerAuthorization(row.peerId);
312
+ if (!this.controlDatabase) {
313
+ throw new Error('Control database not initialized');
128
314
  }
129
-
315
+ const db = this.controlDatabase.getDatabase();
316
+ await db.exec(`
317
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig)
318
+ with context AuthorityKey = ?, Signature = ?
319
+ values (?, ?, ?, ?, ?)
320
+ `, [this.authorityPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig]);
321
+ }
322
+
323
+ /**
324
+ * Authority-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
325
+ *
326
+ * Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
327
+ * row is authority-signed (satisfying `DeviceToken.AuthorizedInsert`, which vouches
328
+ * membership exactly as `CadrePeer.AuthorizedInsert` does) AND carries the peer's
329
+ * own self-`Sig` over the token payload. The authority signature covers only the
330
+ * PeerId — it does NOT vouch the token contents; the peer's `Sig` (verified at
331
+ * resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
332
+ * resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
333
+ * when the node is its own authority.
334
+ */
335
+ async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
336
+ const signature = this.signPeerAuthorization(record.peerId);
130
337
  if (!this.controlDatabase) {
131
338
  throw new Error('Control database not initialized');
132
339
  }
133
-
134
- log('Authorizing peer: %s', peerId);
135
-
136
- // Sign the peer ID with the authority key
137
- const peerIdDigest = digest(peerId, 'sha256', 'utf8', 'base64url') as string;
138
- const signature = sign(
139
- peerIdDigest,
340
+ const db = this.controlDatabase.getDatabase();
341
+ await db.exec(`
342
+ insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig)
343
+ with context AuthorityKey = ?, Signature = ?
344
+ values (?, ?, ?, ?, ?)
345
+ `, [this.authorityPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig]);
346
+ log('Device token inserted (authority-signed): %s', record.peerId);
347
+ }
348
+
349
+ /**
350
+ * Authority-signed DELETE of a peer's `DeviceToken` row (logout / token
351
+ * invalidation). The `DeviceToken.AuthorizedInsert` constraint gates both insert
352
+ * AND delete on an authority signature over `digest(old.PeerId)`, so — like
353
+ * {@link removePeer} for `CadrePeer` — clearing a token requires the authority key.
354
+ */
355
+ async deleteDeviceToken(peerId: string): Promise<void> {
356
+ const signature = this.signPeerAuthorization(peerId);
357
+ if (!this.controlDatabase) {
358
+ throw new Error('Control database not initialized');
359
+ }
360
+ const db = this.controlDatabase.getDatabase();
361
+ await db.exec(`
362
+ delete from CadreControl.DeviceToken
363
+ with context AuthorityKey = ?, Signature = ?
364
+ where PeerId = ?
365
+ `, [this.authorityPublicKey, signature, peerId]);
366
+ log('Device token removed (authority-signed): %s', peerId);
367
+ }
368
+
369
+ /**
370
+ * Sign a peer ID with the authority key for a `CadrePeer` / `DeviceToken`
371
+ * insert-or-delete. The signed bytes come from the shared
372
+ * {@link peerAuthorizationDigest} helper so the offline `cadre enroll register`
373
+ * verifier checks the exact same construction. Throws if no authority key is set.
374
+ */
375
+ private signPeerAuthorization(peerId: string): string {
376
+ if (!this.config.authorityPrivateKey) {
377
+ throw new Error('Authority private key required to authorize peers');
378
+ }
379
+ return sign(
380
+ peerAuthorizationDigest(peerId),
140
381
  this.config.authorityPrivateKey,
141
382
  'ed25519',
142
383
  'base64url',
143
384
  'base64url',
144
385
  'base64url'
145
386
  ) as string;
146
-
147
- // Insert into CadrePeer table with authority context
387
+ }
388
+
389
+ /**
390
+ * Remove a peer from the cadre by authority signature.
391
+ *
392
+ * The constraint over CadrePeer's `check on insert, delete` validates a
393
+ * signature over `digest(old.PeerId)` by an authority key. We use the same
394
+ * digest pattern as authorizePeer.
395
+ */
396
+ async removePeer(peerId: string): Promise<void> {
397
+ // Same canonical digest as the authorizing INSERT (see peerAuthorizationDigest);
398
+ // also validates the authority key is present before touching the database.
399
+ const signature = this.signPeerAuthorization(peerId);
400
+ if (!this.controlDatabase) {
401
+ throw new Error('Control database not initialized');
402
+ }
403
+
404
+ log('Removing peer: %s', peerId);
405
+
148
406
  const db = this.controlDatabase.getDatabase();
149
- // CadrePeer.Multiaddr is NOT NULL; use empty string when no addrs provided
150
- const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
407
+ await db.exec(`
408
+ delete from CadreControl.CadrePeer
409
+ with context AuthorityKey = ?, Signature = ?
410
+ where PeerId = ?
411
+ `, [this.authorityPublicKey, signature, peerId]);
412
+
413
+ log('Peer %s removed successfully', peerId);
414
+ }
151
415
 
416
+ /**
417
+ * Authority "re-touch" of an existing `CadrePeer` membership row: bump
418
+ * `UpdatedAt` under the authority branch of `CadrePeer.AuthorizedUpdate` (a
419
+ * signature over `digest(peerId)` — the same construction {@link authorizePeer}
420
+ * uses) so the row is re-emitted as a fresh, broadcasting transaction.
421
+ *
422
+ * This is the write-while-alone re-replication primitive
423
+ * (`control-write-ensure-replicated`): a membership row that committed
424
+ * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
425
+ * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original
426
+ * INSERT) because the row already exists locally; a re-INSERT would hit the
427
+ * `PeerId` PK. Only the freshness stamp changes — `PublicKey` / `Multiaddr` /
428
+ * `Sig` are left intact — so it is safe over a row whose peer has not
429
+ * self-published (`Sig` null); the caller must skip a row that already carries a
430
+ * self-`Sig` (that row is the owning peer's to refresh, and bumping `UpdatedAt`
431
+ * without re-signing would invalidate its self-signature).
432
+ *
433
+ * @param peerId - the membership row to re-touch.
434
+ * @param updatedAt - the strictly-increasing freshness stamp to write.
435
+ * @throws if no authority private key is configured (a non-authority cannot
436
+ * re-sign another peer's row) or the control database is not initialized.
437
+ */
438
+ async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
439
+ // Reuses the exact digest authorizePeer signs (peerAuthorizationDigest), which
440
+ // the authority branch of AuthorizedUpdate verifies; throws if no authority key.
441
+ const signature = this.signPeerAuthorization(peerId);
442
+ if (!this.controlDatabase) {
443
+ throw new Error('Control database not initialized');
444
+ }
445
+ const db = this.controlDatabase.getDatabase();
152
446
  await db.exec(`
153
- insert into CadreControl.CadrePeer (PeerId, Multiaddr)
447
+ update CadreControl.CadrePeer
154
448
  with context AuthorityKey = ?, Signature = ?
155
- values (?, ?)
156
- `, [this.authorityPublicKey, signature, peerId, multiaddrStr]);
157
-
158
- log('Peer %s authorized successfully', peerId);
449
+ set UpdatedAt = ?
450
+ where PeerId = ?
451
+ `, [this.authorityPublicKey, signature, updatedAt, peerId]);
452
+ log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
159
453
  }
160
454
 
161
455
  /**
@@ -182,9 +476,9 @@ export class SeedBootstrapService {
182
476
  peers,
183
477
  };
184
478
 
185
- // Sign the seed
186
- const seedJson = JSON.stringify(seedData);
187
- const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url') as string;
479
+ // Sign the seed over its canonical byte representation
480
+ const seedJson = canonicalSeedPayload(seedData);
481
+ const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
188
482
  const signature = sign(
189
483
  seedDigest,
190
484
  this.config.authorityPrivateKey,
@@ -206,9 +500,23 @@ export class SeedBootstrapService {
206
500
 
207
501
  /**
208
502
  * Apply a seed to populate the peer cache and enable connections.
209
- * Validates the seed signature before applying.
503
+ *
504
+ * Validates the seed signature, then evaluates a trust anchor for the
505
+ * `signerKey` that does NOT come from the seed body: the receiver's
506
+ * `AuthorityKey` table (DB-anchored), optionally augmented by pinned keys or
507
+ * TOFU via the configured/overriding `SeedTrustPolicy`. A forged
508
+ * self-asserting seed — one that merely lists its own signer as an authority
509
+ * peer — no longer passes.
510
+ *
511
+ * @param seed - The seed to apply (already transport-decoded).
512
+ * @param options.trustPolicy - Per-call policy override (e.g. a
513
+ * `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
514
+ * service-configured default for this seed only.
210
515
  */
211
- async applySeed(seed: ControlNetworkSeed): Promise<ApplySeedResult> {
516
+ async applySeed(
517
+ seed: ControlNetworkSeed,
518
+ options?: { trustPolicy?: SeedTrustPolicy }
519
+ ): Promise<ApplySeedResult> {
212
520
  if (!this.libp2pNode) {
213
521
  return { success: false, peersAdded: 0, error: 'Service not initialized' };
214
522
  }
@@ -220,12 +528,24 @@ export class SeedBootstrapService {
220
528
  return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
221
529
  }
222
530
 
223
- // Verify the signer's key matches an authority peer's public key
224
- const signerIsAuthority = seed.peers.some(
225
- p => p.isAuthority && p.publicKey === seed.signerKey
226
- );
227
- if (!signerIsAuthority) {
228
- return { success: false, peersAdded: 0, error: 'Signer key does not match any authority peer' };
531
+ // Evaluate the trust anchor for the signer key. The known-authority set is
532
+ // sourced from the receiver's control DB, never from the seed itself; a
533
+ // cold-start node with no DB and no override therefore sees an empty set.
534
+ const knownAuthorityKeys = this.controlDatabase
535
+ ? await this.controlDatabase.getAuthorityKeys()
536
+ : new Set<string>();
537
+ const policy = options?.trustPolicy ?? this.trustPolicy;
538
+ const decision = await policy.evaluate({
539
+ partyId: seed.partyId,
540
+ signerKey: seed.signerKey,
541
+ knownAuthorityKeys,
542
+ });
543
+ if (!decision.trusted) {
544
+ return {
545
+ success: false,
546
+ peersAdded: 0,
547
+ error: decision.reason ?? 'Signer key not trusted by trust policy',
548
+ };
229
549
  }
230
550
 
231
551
  let peersAdded = 0;
@@ -300,26 +620,18 @@ export class SeedBootstrapService {
300
620
 
301
621
  // Dial the target and open a stream
302
622
  const rawStream = await this.libp2pNode.dialProtocol(addr, SEED_PROTOCOL);
303
- const stream = rawStream as unknown as LibP2PStream;
623
+ const stream = rawStream as unknown as ControlStream;
304
624
 
305
625
  try {
306
- // Send the seed message
626
+ // Send the seed message as one length-prefixed frame (shared writer).
307
627
  const message: SeedMessage = {
308
628
  partyId: seed.partyId,
309
629
  peers: seed.peers,
310
- transactions: seed.transactions,
311
630
  signature: seed.signature,
312
631
  signerKey: seed.signerKey,
313
632
  };
314
633
 
315
- const messageBytes = new TextEncoder().encode(JSON.stringify(message));
316
-
317
- // Write length-prefixed message using libp2p 3.x send() API
318
- const lengthBytes = new Uint8Array(4);
319
- new DataView(lengthBytes.buffer).setUint32(0, messageBytes.length, false);
320
-
321
- stream.send(lengthBytes);
322
- stream.send(messageBytes);
634
+ writeFrame(stream, message);
323
635
 
324
636
  // In libp2p v3.x, close() closes the write end only (signals EOF),
325
637
  // while the read end remains open for receiving the ack.
@@ -340,8 +652,8 @@ export class SeedBootstrapService {
340
652
  }
341
653
 
342
654
  // Parse length-prefixed response
343
- const responseLength = new DataView(responseData.buffer, responseData.byteOffset).getUint32(0, false);
344
- const responseJson = new TextDecoder().decode(responseData.slice(4, 4 + responseLength));
655
+ const responseBody = decodeLengthPrefixedFrame(responseData);
656
+ const responseJson = new TextDecoder().decode(responseBody);
345
657
  const ack = JSON.parse(responseJson) as SeedAckMessage;
346
658
 
347
659
  log('Seed delivery response: accepted=%s', ack.accepted);
@@ -375,15 +687,11 @@ export class SeedBootstrapService {
375
687
  */
376
688
  validateSeedSignature(seed: ControlNetworkSeed): boolean {
377
689
  try {
378
- // Reconstruct the signed data (seed without signature fields)
379
- const seedData = {
380
- partyId: seed.partyId,
381
- peers: seed.peers,
382
- ...(seed.transactions ? { transactions: seed.transactions } : {}),
383
- };
384
-
385
- const seedJson = JSON.stringify(seedData);
386
- const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url') as string;
690
+ // Reconstruct the signed bytes via the shared canonical payload builder so
691
+ // verification is independent of key order. The payload is the fixed
692
+ // `{ partyId, peers }` the producer emits.
693
+ const seedJson = canonicalSeedPayload(seed);
694
+ const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
387
695
 
388
696
  return verify(
389
697
  seedDigest,
@@ -402,34 +710,39 @@ export class SeedBootstrapService {
402
710
 
403
711
  /**
404
712
  * Query peers from the control database.
713
+ *
714
+ * Authority identity is sourced from the `AuthorityKey` table, not from the
715
+ * transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
716
+ * multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
717
+ * peer is an authority iff that derived key is in the `AuthorityKey` set.
718
+ * This makes any authority node markable — not just the local one — and ties
719
+ * `isAuthority` to the control table rather than to `peerId === self`.
405
720
  */
406
721
  private async queryPeers(): Promise<SeedPeer[]> {
407
722
  if (!this.controlDatabase) {
408
723
  return [];
409
724
  }
410
725
 
726
+ const authorityKeys = await this.controlDatabase.getAuthorityKeys();
411
727
  const db = this.controlDatabase.getDatabase();
412
728
  const peers: SeedPeer[] = [];
413
729
 
414
- // First, get all authority keys
415
- const authorityKeys = new Set<string>();
416
- for await (const row of db.eval('select Key from CadreControl.AuthorityKey')) {
417
- authorityKeys.add(row.Key as string);
418
- }
419
-
420
730
  // Query CadrePeer table
421
731
  for await (const row of db.eval('select PeerId, Multiaddr from CadreControl.CadrePeer')) {
422
732
  const peerId = row.PeerId as string;
423
733
  const multiaddr = row.Multiaddr as string | null;
424
734
 
425
- // Mark peer as authority if it matches the service's own peer ID
426
- const isAuthority = peerId === this.libp2pNode?.peerId.toString();
735
+ // Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
736
+ // unparsable id yields null and is treated as a non-authority rather than
737
+ // failing the whole seed creation.
738
+ const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
739
+ const isAuthority = pubKeyB64 !== null && authorityKeys.has(pubKeyB64);
427
740
 
428
741
  peers.push({
429
742
  peerId,
430
743
  multiaddrs: multiaddr ? multiaddr.split(',') : [],
431
744
  isAuthority,
432
- ...(isAuthority && this.authorityPublicKey ? { publicKey: this.authorityPublicKey } : {}),
745
+ ...(isAuthority ? { publicKey: pubKeyB64 } : {}),
433
746
  });
434
747
  }
435
748
 
@@ -437,105 +750,106 @@ export class SeedBootstrapService {
437
750
  }
438
751
 
439
752
  /**
440
- * Register the seed protocol handler.
753
+ * Register the seed protocol handler. The inbound closure just delegates to
754
+ * {@link handleSeedStream} — extracted as a method so it has a unit-test seam
755
+ * (mirroring wake's `handleStream`) the inline closure never had.
441
756
  */
442
757
  private registerProtocolHandler(): void {
443
758
  if (!this.libp2pNode) return;
444
759
 
445
- this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
446
- const stream = rawStream as LibP2PStream;
760
+ void this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
447
761
  const remotePeerId = (rawConnection as Connection).remotePeer.toString();
448
- log('Incoming seed delivery from: %s', remotePeerId);
762
+ await this.handleSeedStream(rawStream as ControlStream, remotePeerId);
763
+ });
764
+
765
+ log('Registered seed protocol handler: %s', SEED_PROTOCOL);
766
+ }
767
+
768
+ /**
769
+ * Read one inbound seed frame, apply it, and write the ack.
770
+ *
771
+ * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
772
+ * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
773
+ * that never half-closes is aborted inside `readStreamToEnd`), and the existing
774
+ * malformed/oversized-frame guard — all reported as a non-accepting
775
+ * {@link SeedAckMessage} rather than a dropped/hung stream.
776
+ */
777
+ private async handleSeedStream(stream: ControlStream, remotePeerId: string): Promise<void> {
778
+ log('Incoming seed delivery from: %s', remotePeerId);
449
779
 
780
+ if (this.activeStreams >= this.maxConcurrentSeeds) {
781
+ log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
782
+ const ack: SeedAckMessage = { accepted: false, reason: 'Too many concurrent seed deliveries' };
450
783
  try {
451
- // Read the seed message (stream is AsyncIterable in libp2p 3.x)
452
- const chunks: Uint8Array[] = [];
453
- let totalLength = 0;
454
-
455
- for await (const chunk of stream) {
456
- const bytes = chunk instanceof Uint8Array ? chunk : (chunk as { subarray(): Uint8Array }).subarray();
457
- chunks.push(bytes);
458
- totalLength += bytes.length;
459
- if (totalLength > MAX_SEED_SIZE) {
460
- throw new Error('Seed message too large');
461
- }
462
- }
784
+ writeFrame(stream, ack);
785
+ } catch {
786
+ // Ignore send errors on the reject path.
787
+ }
788
+ try {
789
+ await stream.close();
790
+ } catch {
791
+ // Ignore close errors.
792
+ }
793
+ return;
794
+ }
463
795
 
464
- const data = new Uint8Array(totalLength);
465
- let offset = 0;
466
- for (const chunk of chunks) {
467
- data.set(chunk, offset);
468
- offset += chunk.length;
469
- }
796
+ this.activeStreams++;
797
+ try {
798
+ // Read the seed frame to EOF (bounded + size-capped), then decode it.
799
+ const data = await readStreamToEnd(stream, {
800
+ maxBytes: MAX_SEED_SIZE,
801
+ timeoutMs: this.seedReadTimeoutMs,
802
+ label: 'Seed',
803
+ });
470
804
 
471
- // Parse length-prefixed message
472
- const messageLength = new DataView(data.buffer, data.byteOffset).getUint32(0, false);
473
- const messageJson = new TextDecoder().decode(data.slice(4, 4 + messageLength));
474
- const message = JSON.parse(messageJson) as SeedMessage;
475
-
476
- // Emit seed received event
477
- this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
478
-
479
- // Convert to seed and apply
480
- const seed: ControlNetworkSeed = {
481
- partyId: message.partyId,
482
- peers: message.peers,
483
- transactions: message.transactions,
484
- signature: message.signature,
485
- signerKey: message.signerKey,
486
- };
487
-
488
- const result = await this.applySeed(seed);
489
-
490
- // Emit appropriate event based on result
491
- if (result.success) {
492
- this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
493
- } else {
494
- this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
495
- }
805
+ const messageBody = decodeLengthPrefixedFrame(data);
806
+ const messageJson = new TextDecoder().decode(messageBody);
807
+ const message = JSON.parse(messageJson) as SeedMessage;
496
808
 
497
- // Send acknowledgment using libp2p 3.x send() API
498
- const ack: SeedAckMessage = {
499
- accepted: result.success,
500
- reason: result.error,
501
- };
809
+ // Emit seed received event
810
+ this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
502
811
 
503
- const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
504
- const lengthBytes = new Uint8Array(4);
505
- new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
812
+ // Convert to seed and apply
813
+ const seed: ControlNetworkSeed = {
814
+ partyId: message.partyId,
815
+ peers: message.peers,
816
+ signature: message.signature,
817
+ signerKey: message.signerKey,
818
+ };
506
819
 
507
- stream.send(lengthBytes);
508
- stream.send(ackBytes);
820
+ const result = await this.applySeed(seed);
509
821
 
510
- } catch (error) {
511
- log('Error handling seed delivery: %o', error);
512
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
513
-
514
- // Emit error event
515
- this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
516
-
517
- // Send error acknowledgment
518
- const ack: SeedAckMessage = {
519
- accepted: false,
520
- reason: errorMessage,
521
- };
522
-
523
- const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
524
- const lengthBytes = new Uint8Array(4);
525
- new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
526
-
527
- try {
528
- stream.send(lengthBytes);
529
- stream.send(ackBytes);
530
- } catch {
531
- // Ignore send errors
532
- }
533
- } finally {
534
- await stream.close();
822
+ // Emit appropriate event based on result
823
+ if (result.success) {
824
+ this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
825
+ } else {
826
+ this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
535
827
  }
536
- });
537
828
 
538
- log('Registered seed protocol handler: %s', SEED_PROTOCOL);
829
+ const ack: SeedAckMessage = { accepted: result.success, reason: result.error };
830
+ writeFrame(stream, ack);
831
+ } catch (error) {
832
+ log('Error handling seed delivery: %o', error);
833
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
834
+
835
+ // Emit error event
836
+ this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
837
+
838
+ // Send error acknowledgment (best-effort on the still-open write side).
839
+ const ack: SeedAckMessage = { accepted: false, reason: errorMessage };
840
+ try {
841
+ writeFrame(stream, ack);
842
+ } catch {
843
+ // Ignore send errors
844
+ }
845
+ } finally {
846
+ this.activeStreams--;
847
+ try {
848
+ await stream.close();
849
+ } catch {
850
+ // Ignore close errors.
851
+ }
852
+ }
539
853
  }
540
854
 
541
855
  /**
@@ -603,14 +917,33 @@ export class SeedBootstrapService {
603
917
 
604
918
  log('Creating invite for phone');
605
919
 
606
- // Get this node's dialable addresses
607
- const addrs = this.libp2pNode.getMultiaddrs();
608
- const authorityAddrs = addrs.map(a => a.toString());
920
+ // Get this node's dialable addresses. When an inviteAddressResolver is
921
+ // configured (typically by `@serfab/cadre-host`'s NatService), it takes
922
+ // priority — it may substitute a DDNS hostname and externally-mapped
923
+ // port for the raw LAN multiaddrs libp2p reports.
924
+ let authorityAddrs: string[];
925
+ if (this.config.inviteAddressResolver) {
926
+ try {
927
+ authorityAddrs = await this.config.inviteAddressResolver();
928
+ } catch (err) {
929
+ log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
930
+ authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
931
+ }
932
+ } else {
933
+ authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
934
+ }
935
+
936
+ // Carry the cadre's authority keys out-of-band so a cold-start invitee can
937
+ // pin the trusted authority set before applying any seed.
938
+ const authorityKeys = this.controlDatabase
939
+ ? Array.from(await this.controlDatabase.getAuthorityKeys())
940
+ : [];
609
941
 
610
942
  const now = Date.now();
611
943
  const invite: CadreInvite = {
612
944
  partyId: this.config.partyId,
613
945
  authorityAddrs,
946
+ authorityKeys: authorityKeys.length ? authorityKeys : undefined,
614
947
  token,
615
948
  createdAt: now,
616
949
  expiresAt: expiresIn ? now + expiresIn : undefined,
@@ -618,7 +951,7 @@ export class SeedBootstrapService {
618
951
 
619
952
  const encodedInvite = this.encodeInvite(invite);
620
953
 
621
- log('Invite created with %d authority addresses', authorityAddrs.length);
954
+ log('Invite created with %d authority addresses, %d authority keys', authorityAddrs.length, authorityKeys.length);
622
955
 
623
956
  return { invite, encodedInvite };
624
957
  }