@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
@@ -3,24 +3,96 @@ import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } fr
3
3
  import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
4
  import { multiaddr } from '@multiformats/multiaddr';
5
5
  import { peerIdFromString } from '@libp2p/peer-id';
6
+ import { writeFrame, readStreamToEnd } from './control-stream.js';
7
+ import { canonicalJson } from './canonical-json.js';
8
+ import { peerAuthorizationDigest } from './peer-authorization.js';
9
+ import { dbAnchoredTrustPolicy, } from './seed-trust-policy.js';
6
10
  const log = debug('sereus:cadre:seed-bootstrap');
7
11
  /** Protocol ID for seed delivery */
8
12
  export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
9
13
  /** Maximum seed message size (1MB) */
10
14
  const MAX_SEED_SIZE = 1024 * 1024;
15
+ /** Default time the receiver waits for an inbound seed frame before aborting (ms). */
16
+ const DEFAULT_SEED_READ_TIMEOUT_MS = 10000;
17
+ /** Default cap on concurrent inbound seed streams a single peer can pin open. */
18
+ const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
19
+ /**
20
+ * Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
21
+ *
22
+ * Guards every parse site against malformed input: a buffer too short to hold
23
+ * the prefix, a declared length exceeding `maxLength`, and a declared length
24
+ * exceeding the bytes actually present. Returns a view (`subarray`, no copy) —
25
+ * the body is handed straight to `TextDecoder`.
26
+ */
27
+ export function decodeLengthPrefixedFrame(data, maxLength = MAX_SEED_SIZE) {
28
+ if (data.length < 4) {
29
+ throw new Error(`Seed frame too short: ${data.length} bytes, need ≥4 for length prefix`);
30
+ }
31
+ // Pass the full (buffer, byteOffset, byteLength) triple so the read is correct
32
+ // even for a non-zero-offset view, not just the fresh zero-offset arrays
33
+ // current callers pass.
34
+ const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0, false);
35
+ const available = data.length - 4;
36
+ if (length > maxLength) {
37
+ throw new Error(`Seed frame declares length ${length} exceeding max ${maxLength}`);
38
+ }
39
+ if (length > available) {
40
+ throw new Error(`Seed frame declares length ${length} but only ${available} body bytes present`);
41
+ }
42
+ return data.subarray(4, 4 + length);
43
+ }
44
+ /**
45
+ * Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
46
+ *
47
+ * An Ed25519 PeerId is an identity multihash of the public key, so
48
+ * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
49
+ * base64url form matches the `AuthorityKey.Key` representation (and
50
+ * `authorityKeyFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
51
+ * id, a missing embedded key, or any parse failure — callers treat null as
52
+ * "not an authority" rather than throwing.
53
+ */
54
+ export function ed25519PublicKeyB64FromPeerId(peerId) {
55
+ try {
56
+ const parsed = peerIdFromString(peerId);
57
+ if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
58
+ return null;
59
+ }
60
+ return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ /**
67
+ * Canonical byte representation of the authenticated seed fields.
68
+ *
69
+ * Routes both the creator (`createSeed`) and the verifier
70
+ * (`validateSeedSignature`) through one builder so the signed bytes are
71
+ * identical regardless of key insertion order. `canonicalJson` sorts keys and
72
+ * drops `undefined`, so the signed payload is exactly `{ partyId, peers }` —
73
+ * the fields the producer actually emits.
74
+ */
75
+ export function canonicalSeedPayload(seed) {
76
+ return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
77
+ }
11
78
  /**
12
79
  * SeedBootstrapService handles control network seed generation and delivery.
13
80
  *
14
81
  * Seeds solve the cold-start problem: new nodes need control data to validate
15
82
  * connections, but can't get data without connecting first. Seeds pre-populate
16
- * the new node's cache with peer information and optionally transactions.
83
+ * the new node's cache with peer information.
17
84
  */
18
85
  export class SeedBootstrapService {
19
86
  constructor(config) {
20
87
  this.libp2pNode = null;
21
88
  this.controlDatabase = null;
89
+ /** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
90
+ this.activeStreams = 0;
22
91
  this.eventCallbacks = {};
23
92
  this.config = config;
93
+ this.trustPolicy = config.trustPolicy ?? dbAnchoredTrustPolicy();
94
+ this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
95
+ this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
24
96
  // Derive public key from private key if not provided
25
97
  if (config.authorityPrivateKey && !config.authorityPublicKey) {
26
98
  this.authorityPublicKey = getPublicKey(config.authorityPrivateKey, 'ed25519', 'base64url', 'base64url');
@@ -38,41 +110,216 @@ export class SeedBootstrapService {
38
110
  this.eventCallbacks = callbacks;
39
111
  }
40
112
  /**
41
- * Initialize the service with libp2p node and control database
113
+ * Whether this service holds an authority private key, i.e. can produce the
114
+ * authority signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
115
+ * and re-authorizations. A seed-listener-only service (`enableSeedListener`,
116
+ * no authority key) returns false: it can receive/apply seeds but cannot author
117
+ * or re-issue authority writes. Used by the write-while-alone re-replication
118
+ * drain to skip authority work on a non-authority node.
42
119
  */
43
- initialize(libp2pNode, controlDatabase) {
120
+ canAuthorize() {
121
+ return !!this.config.authorityPrivateKey;
122
+ }
123
+ /**
124
+ * Initialize the service with libp2p node and control database.
125
+ *
126
+ * `registerHandler` (default true) gates registration of the shared inbound
127
+ * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
128
+ * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
129
+ * it on. The throwaway temp services CadreNode builds in `applySeed` /
130
+ * `dialInvite` pass `false`: they only need the stored `libp2pNode` /
131
+ * `controlDatabase` for dialing and known-key lookup, and must NOT bind a
132
+ * discarded closure to the shared node (a handler leak, and a second
133
+ * `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
134
+ */
135
+ initialize(libp2pNode, controlDatabase, options) {
44
136
  this.libp2pNode = libp2pNode;
45
137
  this.controlDatabase = controlDatabase;
46
- // Register the seed protocol handler
47
- this.registerProtocolHandler();
138
+ // Register the seed protocol handler unless the caller opted out (temp services).
139
+ if (options?.registerHandler ?? true) {
140
+ this.registerProtocolHandler();
141
+ }
48
142
  log('SeedBootstrapService initialized');
49
143
  }
50
144
  /**
51
145
  * Authorize a new peer to join the cadre.
52
146
  * Signs the peer ID with the authority key and inserts into CadrePeer table.
147
+ *
148
+ * The authority vouches the `PublicKey <-> PeerId` binding: rather than trust a
149
+ * caller-supplied key, the binding is enforced by construction — `PublicKey` is
150
+ * DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
151
+ * `PublicKey`, and such a row can never be self-updated (it has no key to
152
+ * verify against), which is correct. The row is inserted with a fresh
153
+ * `UpdatedAt` but no self-signature (`Sig` null) — the authority cannot produce
154
+ * the peer's self-signature, so the peer must self-publish (see
155
+ * {@link CadreNode.registerSelf}) before the row resolves.
53
156
  */
54
157
  async authorizePeer(options) {
55
158
  const { peerId, multiaddrs } = options;
159
+ log('Authorizing peer: %s', peerId);
160
+ // CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
161
+ const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
162
+ await this.insertCadrePeerRow({
163
+ peerId,
164
+ publicKey: ed25519PublicKeyB64FromPeerId(peerId),
165
+ multiaddr: multiaddrStr,
166
+ updatedAt: Date.now(),
167
+ sig: null,
168
+ });
169
+ log('Peer %s authorized successfully', peerId);
170
+ }
171
+ /**
172
+ * Authority-signed INSERT of this node's OWN self-signed address record.
173
+ *
174
+ * Used by {@link CadreNode.registerSelf} when the node is not yet a member and
175
+ * is its own authority (it holds the authority key): the row is authority-signed
176
+ * (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
177
+ * immediately without a follow-up self-update.
178
+ */
179
+ async insertSelfPeerRecord(record) {
180
+ await this.insertCadrePeerRow({
181
+ peerId: record.peerId,
182
+ publicKey: record.publicKey,
183
+ multiaddr: record.addrs.join(','),
184
+ updatedAt: record.updatedAt,
185
+ sig: record.sig,
186
+ });
187
+ }
188
+ /**
189
+ * Shared authority-signed `CadrePeer` INSERT. Signs `digest(peerId)` with the
190
+ * authority key (satisfying `AuthorizedInsert`) and writes the full record
191
+ * row. The authority signature does NOT cover the address columns — those are
192
+ * vouched only as far as the authority asserts them, and a peer's own `Sig`
193
+ * (when present) is what makes the row resolvable.
194
+ */
195
+ async insertCadrePeerRow(row) {
196
+ const signature = this.signPeerAuthorization(row.peerId);
197
+ if (!this.controlDatabase) {
198
+ throw new Error('Control database not initialized');
199
+ }
200
+ const db = this.controlDatabase.getDatabase();
201
+ await db.exec(`
202
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig)
203
+ with context AuthorityKey = ?, Signature = ?
204
+ values (?, ?, ?, ?, ?)
205
+ `, [this.authorityPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig]);
206
+ }
207
+ /**
208
+ * Authority-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
209
+ *
210
+ * Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
211
+ * row is authority-signed (satisfying `DeviceToken.AuthorizedInsert`, which vouches
212
+ * membership exactly as `CadrePeer.AuthorizedInsert` does) AND carries the peer's
213
+ * own self-`Sig` over the token payload. The authority signature covers only the
214
+ * PeerId — it does NOT vouch the token contents; the peer's `Sig` (verified at
215
+ * resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
216
+ * resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
217
+ * when the node is its own authority.
218
+ */
219
+ async insertSelfDeviceToken(record) {
220
+ const signature = this.signPeerAuthorization(record.peerId);
221
+ if (!this.controlDatabase) {
222
+ throw new Error('Control database not initialized');
223
+ }
224
+ const db = this.controlDatabase.getDatabase();
225
+ await db.exec(`
226
+ insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig)
227
+ with context AuthorityKey = ?, Signature = ?
228
+ values (?, ?, ?, ?, ?)
229
+ `, [this.authorityPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig]);
230
+ log('Device token inserted (authority-signed): %s', record.peerId);
231
+ }
232
+ /**
233
+ * Authority-signed DELETE of a peer's `DeviceToken` row (logout / token
234
+ * invalidation). The `DeviceToken.AuthorizedInsert` constraint gates both insert
235
+ * AND delete on an authority signature over `digest(old.PeerId)`, so — like
236
+ * {@link removePeer} for `CadrePeer` — clearing a token requires the authority key.
237
+ */
238
+ async deleteDeviceToken(peerId) {
239
+ const signature = this.signPeerAuthorization(peerId);
240
+ if (!this.controlDatabase) {
241
+ throw new Error('Control database not initialized');
242
+ }
243
+ const db = this.controlDatabase.getDatabase();
244
+ await db.exec(`
245
+ delete from CadreControl.DeviceToken
246
+ with context AuthorityKey = ?, Signature = ?
247
+ where PeerId = ?
248
+ `, [this.authorityPublicKey, signature, peerId]);
249
+ log('Device token removed (authority-signed): %s', peerId);
250
+ }
251
+ /**
252
+ * Sign a peer ID with the authority key for a `CadrePeer` / `DeviceToken`
253
+ * insert-or-delete. The signed bytes come from the shared
254
+ * {@link peerAuthorizationDigest} helper so the offline `cadre enroll register`
255
+ * verifier checks the exact same construction. Throws if no authority key is set.
256
+ */
257
+ signPeerAuthorization(peerId) {
56
258
  if (!this.config.authorityPrivateKey) {
57
259
  throw new Error('Authority private key required to authorize peers');
58
260
  }
261
+ return sign(peerAuthorizationDigest(peerId), this.config.authorityPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
262
+ }
263
+ /**
264
+ * Remove a peer from the cadre by authority signature.
265
+ *
266
+ * The constraint over CadrePeer's `check on insert, delete` validates a
267
+ * signature over `digest(old.PeerId)` by an authority key. We use the same
268
+ * digest pattern as authorizePeer.
269
+ */
270
+ async removePeer(peerId) {
271
+ // Same canonical digest as the authorizing INSERT (see peerAuthorizationDigest);
272
+ // also validates the authority key is present before touching the database.
273
+ const signature = this.signPeerAuthorization(peerId);
274
+ if (!this.controlDatabase) {
275
+ throw new Error('Control database not initialized');
276
+ }
277
+ log('Removing peer: %s', peerId);
278
+ const db = this.controlDatabase.getDatabase();
279
+ await db.exec(`
280
+ delete from CadreControl.CadrePeer
281
+ with context AuthorityKey = ?, Signature = ?
282
+ where PeerId = ?
283
+ `, [this.authorityPublicKey, signature, peerId]);
284
+ log('Peer %s removed successfully', peerId);
285
+ }
286
+ /**
287
+ * Authority "re-touch" of an existing `CadrePeer` membership row: bump
288
+ * `UpdatedAt` under the authority branch of `CadrePeer.AuthorizedUpdate` (a
289
+ * signature over `digest(peerId)` — the same construction {@link authorizePeer}
290
+ * uses) so the row is re-emitted as a fresh, broadcasting transaction.
291
+ *
292
+ * This is the write-while-alone re-replication primitive
293
+ * (`control-write-ensure-replicated`): a membership row that committed
294
+ * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
295
+ * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original
296
+ * INSERT) because the row already exists locally; a re-INSERT would hit the
297
+ * `PeerId` PK. Only the freshness stamp changes — `PublicKey` / `Multiaddr` /
298
+ * `Sig` are left intact — so it is safe over a row whose peer has not
299
+ * self-published (`Sig` null); the caller must skip a row that already carries a
300
+ * self-`Sig` (that row is the owning peer's to refresh, and bumping `UpdatedAt`
301
+ * without re-signing would invalidate its self-signature).
302
+ *
303
+ * @param peerId - the membership row to re-touch.
304
+ * @param updatedAt - the strictly-increasing freshness stamp to write.
305
+ * @throws if no authority private key is configured (a non-authority cannot
306
+ * re-sign another peer's row) or the control database is not initialized.
307
+ */
308
+ async reauthorizePeer(peerId, updatedAt) {
309
+ // Reuses the exact digest authorizePeer signs (peerAuthorizationDigest), which
310
+ // the authority branch of AuthorizedUpdate verifies; throws if no authority key.
311
+ const signature = this.signPeerAuthorization(peerId);
59
312
  if (!this.controlDatabase) {
60
313
  throw new Error('Control database not initialized');
61
314
  }
62
- log('Authorizing peer: %s', peerId);
63
- // Sign the peer ID with the authority key
64
- const peerIdDigest = digest(peerId, 'sha256', 'utf8', 'base64url');
65
- const signature = sign(peerIdDigest, this.config.authorityPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
66
- // Insert into CadrePeer table with authority context
67
315
  const db = this.controlDatabase.getDatabase();
68
- // CadrePeer.Multiaddr is NOT NULL; use empty string when no addrs provided
69
- const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
70
316
  await db.exec(`
71
- insert into CadreControl.CadrePeer (PeerId, Multiaddr)
317
+ update CadreControl.CadrePeer
72
318
  with context AuthorityKey = ?, Signature = ?
73
- values (?, ?)
74
- `, [this.authorityPublicKey, signature, peerId, multiaddrStr]);
75
- log('Peer %s authorized successfully', peerId);
319
+ set UpdatedAt = ?
320
+ where PeerId = ?
321
+ `, [this.authorityPublicKey, signature, updatedAt, peerId]);
322
+ log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
76
323
  }
77
324
  /**
78
325
  * Create a seed from the current control network state.
@@ -93,9 +340,9 @@ export class SeedBootstrapService {
93
340
  partyId: this.config.partyId,
94
341
  peers,
95
342
  };
96
- // Sign the seed
97
- const seedJson = JSON.stringify(seedData);
98
- const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url');
343
+ // Sign the seed over its canonical byte representation
344
+ const seedJson = canonicalSeedPayload(seedData);
345
+ const seedDigest = digest([seedJson], 'sha256', 'base64url');
99
346
  const signature = sign(seedDigest, this.config.authorityPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
100
347
  const seed = {
101
348
  ...seedData,
@@ -107,9 +354,20 @@ export class SeedBootstrapService {
107
354
  }
108
355
  /**
109
356
  * Apply a seed to populate the peer cache and enable connections.
110
- * Validates the seed signature before applying.
357
+ *
358
+ * Validates the seed signature, then evaluates a trust anchor for the
359
+ * `signerKey` that does NOT come from the seed body: the receiver's
360
+ * `AuthorityKey` table (DB-anchored), optionally augmented by pinned keys or
361
+ * TOFU via the configured/overriding `SeedTrustPolicy`. A forged
362
+ * self-asserting seed — one that merely lists its own signer as an authority
363
+ * peer — no longer passes.
364
+ *
365
+ * @param seed - The seed to apply (already transport-decoded).
366
+ * @param options.trustPolicy - Per-call policy override (e.g. a
367
+ * `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
368
+ * service-configured default for this seed only.
111
369
  */
112
- async applySeed(seed) {
370
+ async applySeed(seed, options) {
113
371
  if (!this.libp2pNode) {
114
372
  return { success: false, peersAdded: 0, error: 'Service not initialized' };
115
373
  }
@@ -118,10 +376,24 @@ export class SeedBootstrapService {
118
376
  if (!this.validateSeedSignature(seed)) {
119
377
  return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
120
378
  }
121
- // Verify the signer's key matches an authority peer's public key
122
- const signerIsAuthority = seed.peers.some(p => p.isAuthority && p.publicKey === seed.signerKey);
123
- if (!signerIsAuthority) {
124
- return { success: false, peersAdded: 0, error: 'Signer key does not match any authority peer' };
379
+ // Evaluate the trust anchor for the signer key. The known-authority set is
380
+ // sourced from the receiver's control DB, never from the seed itself; a
381
+ // cold-start node with no DB and no override therefore sees an empty set.
382
+ const knownAuthorityKeys = this.controlDatabase
383
+ ? await this.controlDatabase.getAuthorityKeys()
384
+ : new Set();
385
+ const policy = options?.trustPolicy ?? this.trustPolicy;
386
+ const decision = await policy.evaluate({
387
+ partyId: seed.partyId,
388
+ signerKey: seed.signerKey,
389
+ knownAuthorityKeys,
390
+ });
391
+ if (!decision.trusted) {
392
+ return {
393
+ success: false,
394
+ peersAdded: 0,
395
+ error: decision.reason ?? 'Signer key not trusted by trust policy',
396
+ };
125
397
  }
126
398
  let peersAdded = 0;
127
399
  // Add peers to the peer store
@@ -187,20 +459,14 @@ export class SeedBootstrapService {
187
459
  const rawStream = await this.libp2pNode.dialProtocol(addr, SEED_PROTOCOL);
188
460
  const stream = rawStream;
189
461
  try {
190
- // Send the seed message
462
+ // Send the seed message as one length-prefixed frame (shared writer).
191
463
  const message = {
192
464
  partyId: seed.partyId,
193
465
  peers: seed.peers,
194
- transactions: seed.transactions,
195
466
  signature: seed.signature,
196
467
  signerKey: seed.signerKey,
197
468
  };
198
- const messageBytes = new TextEncoder().encode(JSON.stringify(message));
199
- // Write length-prefixed message using libp2p 3.x send() API
200
- const lengthBytes = new Uint8Array(4);
201
- new DataView(lengthBytes.buffer).setUint32(0, messageBytes.length, false);
202
- stream.send(lengthBytes);
203
- stream.send(messageBytes);
469
+ writeFrame(stream, message);
204
470
  // In libp2p v3.x, close() closes the write end only (signals EOF),
205
471
  // while the read end remains open for receiving the ack.
206
472
  await stream.close();
@@ -217,8 +483,8 @@ export class SeedBootstrapService {
217
483
  offset += chunk.length;
218
484
  }
219
485
  // Parse length-prefixed response
220
- const responseLength = new DataView(responseData.buffer, responseData.byteOffset).getUint32(0, false);
221
- const responseJson = new TextDecoder().decode(responseData.slice(4, 4 + responseLength));
486
+ const responseBody = decodeLengthPrefixedFrame(responseData);
487
+ const responseJson = new TextDecoder().decode(responseBody);
222
488
  const ack = JSON.parse(responseJson);
223
489
  log('Seed delivery response: accepted=%s', ack.accepted);
224
490
  return ack;
@@ -246,14 +512,11 @@ export class SeedBootstrapService {
246
512
  */
247
513
  validateSeedSignature(seed) {
248
514
  try {
249
- // Reconstruct the signed data (seed without signature fields)
250
- const seedData = {
251
- partyId: seed.partyId,
252
- peers: seed.peers,
253
- ...(seed.transactions ? { transactions: seed.transactions } : {}),
254
- };
255
- const seedJson = JSON.stringify(seedData);
256
- const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url');
515
+ // Reconstruct the signed bytes via the shared canonical payload builder so
516
+ // verification is independent of key order. The payload is the fixed
517
+ // `{ partyId, peers }` the producer emits.
518
+ const seedJson = canonicalSeedPayload(seed);
519
+ const seedDigest = digest([seedJson], 'sha256', 'base64url');
257
520
  return verify(seedDigest, seed.signature, seed.signerKey, 'ed25519', 'base64url', 'base64url', 'base64url');
258
521
  }
259
522
  catch (error) {
@@ -263,120 +526,135 @@ export class SeedBootstrapService {
263
526
  }
264
527
  /**
265
528
  * Query peers from the control database.
529
+ *
530
+ * Authority identity is sourced from the `AuthorityKey` table, not from the
531
+ * transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
532
+ * multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
533
+ * peer is an authority iff that derived key is in the `AuthorityKey` set.
534
+ * This makes any authority node markable — not just the local one — and ties
535
+ * `isAuthority` to the control table rather than to `peerId === self`.
266
536
  */
267
537
  async queryPeers() {
268
538
  if (!this.controlDatabase) {
269
539
  return [];
270
540
  }
541
+ const authorityKeys = await this.controlDatabase.getAuthorityKeys();
271
542
  const db = this.controlDatabase.getDatabase();
272
543
  const peers = [];
273
- // First, get all authority keys
274
- const authorityKeys = new Set();
275
- for await (const row of db.eval('select Key from CadreControl.AuthorityKey')) {
276
- authorityKeys.add(row.Key);
277
- }
278
544
  // Query CadrePeer table
279
545
  for await (const row of db.eval('select PeerId, Multiaddr from CadreControl.CadrePeer')) {
280
546
  const peerId = row.PeerId;
281
547
  const multiaddr = row.Multiaddr;
282
- // Mark peer as authority if it matches the service's own peer ID
283
- const isAuthority = peerId === this.libp2pNode?.peerId.toString();
548
+ // Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
549
+ // unparsable id yields null and is treated as a non-authority rather than
550
+ // failing the whole seed creation.
551
+ const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
552
+ const isAuthority = pubKeyB64 !== null && authorityKeys.has(pubKeyB64);
284
553
  peers.push({
285
554
  peerId,
286
555
  multiaddrs: multiaddr ? multiaddr.split(',') : [],
287
556
  isAuthority,
288
- ...(isAuthority && this.authorityPublicKey ? { publicKey: this.authorityPublicKey } : {}),
557
+ ...(isAuthority ? { publicKey: pubKeyB64 } : {}),
289
558
  });
290
559
  }
291
560
  return peers;
292
561
  }
293
562
  /**
294
- * Register the seed protocol handler.
563
+ * Register the seed protocol handler. The inbound closure just delegates to
564
+ * {@link handleSeedStream} — extracted as a method so it has a unit-test seam
565
+ * (mirroring wake's `handleStream`) the inline closure never had.
295
566
  */
296
567
  registerProtocolHandler() {
297
568
  if (!this.libp2pNode)
298
569
  return;
299
- this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream, rawConnection) => {
300
- const stream = rawStream;
570
+ void this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream, rawConnection) => {
301
571
  const remotePeerId = rawConnection.remotePeer.toString();
302
- log('Incoming seed delivery from: %s', remotePeerId);
572
+ await this.handleSeedStream(rawStream, remotePeerId);
573
+ });
574
+ log('Registered seed protocol handler: %s', SEED_PROTOCOL);
575
+ }
576
+ /**
577
+ * Read one inbound seed frame, apply it, and write the ack.
578
+ *
579
+ * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
580
+ * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
581
+ * that never half-closes is aborted inside `readStreamToEnd`), and the existing
582
+ * malformed/oversized-frame guard — all reported as a non-accepting
583
+ * {@link SeedAckMessage} rather than a dropped/hung stream.
584
+ */
585
+ async handleSeedStream(stream, remotePeerId) {
586
+ log('Incoming seed delivery from: %s', remotePeerId);
587
+ if (this.activeStreams >= this.maxConcurrentSeeds) {
588
+ log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
589
+ const ack = { accepted: false, reason: 'Too many concurrent seed deliveries' };
303
590
  try {
304
- // Read the seed message (stream is AsyncIterable in libp2p 3.x)
305
- const chunks = [];
306
- let totalLength = 0;
307
- for await (const chunk of stream) {
308
- const bytes = chunk instanceof Uint8Array ? chunk : chunk.subarray();
309
- chunks.push(bytes);
310
- totalLength += bytes.length;
311
- if (totalLength > MAX_SEED_SIZE) {
312
- throw new Error('Seed message too large');
313
- }
314
- }
315
- const data = new Uint8Array(totalLength);
316
- let offset = 0;
317
- for (const chunk of chunks) {
318
- data.set(chunk, offset);
319
- offset += chunk.length;
320
- }
321
- // Parse length-prefixed message
322
- const messageLength = new DataView(data.buffer, data.byteOffset).getUint32(0, false);
323
- const messageJson = new TextDecoder().decode(data.slice(4, 4 + messageLength));
324
- const message = JSON.parse(messageJson);
325
- // Emit seed received event
326
- this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
327
- // Convert to seed and apply
328
- const seed = {
329
- partyId: message.partyId,
330
- peers: message.peers,
331
- transactions: message.transactions,
332
- signature: message.signature,
333
- signerKey: message.signerKey,
334
- };
335
- const result = await this.applySeed(seed);
336
- // Emit appropriate event based on result
337
- if (result.success) {
338
- this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
339
- }
340
- else {
341
- this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
342
- }
343
- // Send acknowledgment using libp2p 3.x send() API
344
- const ack = {
345
- accepted: result.success,
346
- reason: result.error,
347
- };
348
- const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
349
- const lengthBytes = new Uint8Array(4);
350
- new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
351
- stream.send(lengthBytes);
352
- stream.send(ackBytes);
591
+ writeFrame(stream, ack);
353
592
  }
354
- catch (error) {
355
- log('Error handling seed delivery: %o', error);
356
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
357
- // Emit error event
358
- this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
359
- // Send error acknowledgment
360
- const ack = {
361
- accepted: false,
362
- reason: errorMessage,
363
- };
364
- const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
365
- const lengthBytes = new Uint8Array(4);
366
- new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
367
- try {
368
- stream.send(lengthBytes);
369
- stream.send(ackBytes);
370
- }
371
- catch {
372
- // Ignore send errors
373
- }
593
+ catch {
594
+ // Ignore send errors on the reject path.
374
595
  }
375
- finally {
596
+ try {
376
597
  await stream.close();
377
598
  }
378
- });
379
- log('Registered seed protocol handler: %s', SEED_PROTOCOL);
599
+ catch {
600
+ // Ignore close errors.
601
+ }
602
+ return;
603
+ }
604
+ this.activeStreams++;
605
+ try {
606
+ // Read the seed frame to EOF (bounded + size-capped), then decode it.
607
+ const data = await readStreamToEnd(stream, {
608
+ maxBytes: MAX_SEED_SIZE,
609
+ timeoutMs: this.seedReadTimeoutMs,
610
+ label: 'Seed',
611
+ });
612
+ const messageBody = decodeLengthPrefixedFrame(data);
613
+ const messageJson = new TextDecoder().decode(messageBody);
614
+ const message = JSON.parse(messageJson);
615
+ // Emit seed received event
616
+ this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
617
+ // Convert to seed and apply
618
+ const seed = {
619
+ partyId: message.partyId,
620
+ peers: message.peers,
621
+ signature: message.signature,
622
+ signerKey: message.signerKey,
623
+ };
624
+ const result = await this.applySeed(seed);
625
+ // Emit appropriate event based on result
626
+ if (result.success) {
627
+ this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
628
+ }
629
+ else {
630
+ this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
631
+ }
632
+ const ack = { accepted: result.success, reason: result.error };
633
+ writeFrame(stream, ack);
634
+ }
635
+ catch (error) {
636
+ log('Error handling seed delivery: %o', error);
637
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
638
+ // Emit error event
639
+ this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
640
+ // Send error acknowledgment (best-effort on the still-open write side).
641
+ const ack = { accepted: false, reason: errorMessage };
642
+ try {
643
+ writeFrame(stream, ack);
644
+ }
645
+ catch {
646
+ // Ignore send errors
647
+ }
648
+ }
649
+ finally {
650
+ this.activeStreams--;
651
+ try {
652
+ await stream.close();
653
+ }
654
+ catch {
655
+ // Ignore close errors.
656
+ }
657
+ }
380
658
  }
381
659
  /**
382
660
  * Shutdown the service.
@@ -432,19 +710,39 @@ export class SeedBootstrapService {
432
710
  throw new Error('Service not initialized');
433
711
  }
434
712
  log('Creating invite for phone');
435
- // Get this node's dialable addresses
436
- const addrs = this.libp2pNode.getMultiaddrs();
437
- const authorityAddrs = addrs.map(a => a.toString());
713
+ // Get this node's dialable addresses. When an inviteAddressResolver is
714
+ // configured (typically by `@serfab/cadre-host`'s NatService), it takes
715
+ // priority — it may substitute a DDNS hostname and externally-mapped
716
+ // port for the raw LAN multiaddrs libp2p reports.
717
+ let authorityAddrs;
718
+ if (this.config.inviteAddressResolver) {
719
+ try {
720
+ authorityAddrs = await this.config.inviteAddressResolver();
721
+ }
722
+ catch (err) {
723
+ log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
724
+ authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
725
+ }
726
+ }
727
+ else {
728
+ authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
729
+ }
730
+ // Carry the cadre's authority keys out-of-band so a cold-start invitee can
731
+ // pin the trusted authority set before applying any seed.
732
+ const authorityKeys = this.controlDatabase
733
+ ? Array.from(await this.controlDatabase.getAuthorityKeys())
734
+ : [];
438
735
  const now = Date.now();
439
736
  const invite = {
440
737
  partyId: this.config.partyId,
441
738
  authorityAddrs,
739
+ authorityKeys: authorityKeys.length ? authorityKeys : undefined,
442
740
  token,
443
741
  createdAt: now,
444
742
  expiresAt: expiresIn ? now + expiresIn : undefined,
445
743
  };
446
744
  const encodedInvite = this.encodeInvite(invite);
447
- log('Invite created with %d authority addresses', authorityAddrs.length);
745
+ log('Invite created with %d authority addresses, %d authority keys', authorityAddrs.length, authorityKeys.length);
448
746
  return { invite, encodedInvite };
449
747
  }
450
748
  /**