@serfab/cadre-core 0.7.1 → 0.9.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 (170) hide show
  1. package/README.md +11 -10
  2. package/dist/cadre-node.d.ts +818 -19
  3. package/dist/cadre-node.js +1952 -99
  4. package/dist/cadre-node.js.map +1 -1
  5. package/dist/canonical-datetime.d.ts +33 -0
  6. package/dist/canonical-datetime.js +38 -0
  7. package/dist/canonical-datetime.js.map +1 -0
  8. package/dist/canonical-json.d.ts +12 -0
  9. package/dist/canonical-json.js +33 -0
  10. package/dist/canonical-json.js.map +1 -0
  11. package/dist/control-cohort.d.ts +63 -0
  12. package/dist/control-cohort.js +80 -0
  13. package/dist/control-cohort.js.map +1 -0
  14. package/dist/control-database.d.ts +279 -8
  15. package/dist/control-database.js +563 -134
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -0
  18. package/dist/control-formation-recorder.js +139 -0
  19. package/dist/control-formation-recorder.js.map +1 -0
  20. package/dist/control-schema.d.ts +12 -0
  21. package/dist/control-schema.js +223 -0
  22. package/dist/control-schema.js.map +1 -0
  23. package/dist/control-stream.d.ts +54 -0
  24. package/dist/control-stream.js +104 -0
  25. package/dist/control-stream.js.map +1 -0
  26. package/dist/device-token.d.ts +50 -0
  27. package/dist/device-token.js +66 -0
  28. package/dist/device-token.js.map +1 -0
  29. package/dist/diagnostics/connection-path.d.ts +118 -0
  30. package/dist/diagnostics/connection-path.js +204 -0
  31. package/dist/diagnostics/connection-path.js.map +1 -0
  32. package/dist/diagnostics/webrtc-turn-tracker.d.ts +50 -0
  33. package/dist/diagnostics/webrtc-turn-tracker.js +196 -0
  34. package/dist/diagnostics/webrtc-turn-tracker.js.map +1 -0
  35. package/dist/ed25519-key.d.ts +42 -0
  36. package/dist/ed25519-key.js +51 -0
  37. package/dist/ed25519-key.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 +48 -0
  51. package/dist/peer-authorization.js +66 -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-node.d.ts +38 -0
  63. package/dist/push-node.js +48 -0
  64. package/dist/push-node.js.map +1 -0
  65. package/dist/push-notifier-apns.d.ts +61 -0
  66. package/dist/push-notifier-apns.js +199 -0
  67. package/dist/push-notifier-apns.js.map +1 -0
  68. package/dist/push-notifier-fcm.d.ts +44 -0
  69. package/dist/push-notifier-fcm.js +182 -0
  70. package/dist/push-notifier-fcm.js.map +1 -0
  71. package/dist/push-notifier-shared.d.ts +18 -0
  72. package/dist/push-notifier-shared.js +25 -0
  73. package/dist/push-notifier-shared.js.map +1 -0
  74. package/dist/push-notifier.d.ts +52 -0
  75. package/dist/push-notifier.js +22 -0
  76. package/dist/push-notifier.js.map +1 -0
  77. package/dist/schema-verification.d.ts +11 -2
  78. package/dist/schema-verification.js +15 -4
  79. package/dist/schema-verification.js.map +1 -1
  80. package/dist/seed-bootstrap.d.ts +228 -21
  81. package/dist/seed-bootstrap.js +489 -168
  82. package/dist/seed-bootstrap.js.map +1 -1
  83. package/dist/seed-trust-policy.d.ts +63 -0
  84. package/dist/seed-trust-policy.js +56 -0
  85. package/dist/seed-trust-policy.js.map +1 -0
  86. package/dist/strand-addr-protocol.d.ts +132 -0
  87. package/dist/strand-addr-protocol.js +313 -0
  88. package/dist/strand-addr-protocol.js.map +1 -0
  89. package/dist/strand-cohort.d.ts +54 -0
  90. package/dist/strand-cohort.js +38 -0
  91. package/dist/strand-cohort.js.map +1 -0
  92. package/dist/strand-database.d.ts +54 -6
  93. package/dist/strand-database.js +88 -100
  94. package/dist/strand-database.js.map +1 -1
  95. package/dist/strand-formation-manager.d.ts +79 -19
  96. package/dist/strand-formation-manager.js +196 -104
  97. package/dist/strand-formation-manager.js.map +1 -1
  98. package/dist/strand-formation-protocol.d.ts +162 -0
  99. package/dist/strand-formation-protocol.js +279 -0
  100. package/dist/strand-formation-protocol.js.map +1 -0
  101. package/dist/strand-instance-manager.d.ts +73 -4
  102. package/dist/strand-instance-manager.js +167 -41
  103. package/dist/strand-instance-manager.js.map +1 -1
  104. package/dist/strand-member-key.d.ts +29 -0
  105. package/dist/strand-member-key.js +38 -0
  106. package/dist/strand-member-key.js.map +1 -0
  107. package/dist/strand-member-registry.d.ts +98 -0
  108. package/dist/strand-member-registry.js +129 -0
  109. package/dist/strand-member-registry.js.map +1 -0
  110. package/dist/strand-membership-writer.d.ts +361 -0
  111. package/dist/strand-membership-writer.js +449 -0
  112. package/dist/strand-membership-writer.js.map +1 -0
  113. package/dist/strand-solicitation.d.ts +77 -2
  114. package/dist/strand-solicitation.js +25 -4
  115. package/dist/strand-solicitation.js.map +1 -1
  116. package/dist/strand-wake-payload.d.ts +30 -0
  117. package/dist/strand-wake-payload.js +20 -0
  118. package/dist/strand-wake-payload.js.map +1 -0
  119. package/dist/strand-wake-protocol.d.ts +114 -0
  120. package/dist/strand-wake-protocol.js +257 -0
  121. package/dist/strand-wake-protocol.js.map +1 -0
  122. package/dist/strand-watcher.d.ts +6 -2
  123. package/dist/strand-watcher.js +57 -17
  124. package/dist/strand-watcher.js.map +1 -1
  125. package/dist/types.d.ts +436 -33
  126. package/dist/types.js +22 -5
  127. package/dist/types.js.map +1 -1
  128. package/package.json +18 -8
  129. package/src/cadre-node.ts +2981 -948
  130. package/src/canonical-datetime.ts +39 -0
  131. package/src/canonical-json.ts +30 -0
  132. package/src/control-cohort.ts +103 -0
  133. package/src/control-database.ts +948 -383
  134. package/src/control-formation-recorder.ts +151 -0
  135. package/src/control-schema.ts +222 -0
  136. package/src/control-stream.ts +128 -0
  137. package/src/device-token.ts +89 -0
  138. package/src/diagnostics/connection-path.ts +277 -0
  139. package/src/diagnostics/webrtc-turn-tracker.ts +222 -0
  140. package/src/ed25519-key.ts +69 -0
  141. package/src/hibernation-manager.ts +197 -28
  142. package/src/index.ts +275 -79
  143. package/src/key-store-file.ts +203 -0
  144. package/src/key-store.ts +99 -0
  145. package/src/peer-authorization.ts +81 -0
  146. package/src/peer-record.ts +146 -0
  147. package/src/push-credentials.ts +84 -0
  148. package/src/push-fanout.ts +272 -0
  149. package/src/push-node.ts +64 -0
  150. package/src/push-notifier-apns.ts +247 -0
  151. package/src/push-notifier-fcm.ts +220 -0
  152. package/src/push-notifier-shared.ts +28 -0
  153. package/src/push-notifier.ts +52 -0
  154. package/src/schema-verification.ts +18 -4
  155. package/src/seed-bootstrap.ts +563 -223
  156. package/src/seed-trust-policy.ts +107 -0
  157. package/src/strand-addr-protocol.ts +410 -0
  158. package/src/strand-cohort.ts +77 -0
  159. package/src/strand-database.ts +113 -141
  160. package/src/strand-formation-manager.ts +233 -142
  161. package/src/strand-formation-protocol.ts +391 -0
  162. package/src/strand-instance-manager.ts +207 -56
  163. package/src/strand-member-key.ts +39 -0
  164. package/src/strand-member-registry.ts +177 -0
  165. package/src/strand-membership-writer.ts +640 -0
  166. package/src/strand-solicitation.ts +93 -4
  167. package/src/strand-wake-payload.ts +32 -0
  168. package/src/strand-wake-protocol.ts +331 -0
  169. package/src/strand-watcher.ts +61 -17
  170. package/src/types.ts +1032 -582
@@ -3,30 +3,103 @@ 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 { generateStampId } from './control-database.js';
8
+ import { canonicalJson } from './canonical-json.js';
9
+ import { peerAuthorizationDigest, cadrePeerVoucherDigest, cadrePeerRemoveDigest } from './peer-authorization.js';
10
+ import { dbAnchoredTrustPolicy, } from './seed-trust-policy.js';
6
11
  const log = debug('sereus:cadre:seed-bootstrap');
7
12
  /** Protocol ID for seed delivery */
8
13
  export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
9
14
  /** Maximum seed message size (1MB) */
10
15
  const MAX_SEED_SIZE = 1024 * 1024;
16
+ /** Default time the receiver waits for an inbound seed frame before aborting (ms). */
17
+ const DEFAULT_SEED_READ_TIMEOUT_MS = 10000;
18
+ /** Default cap on concurrent inbound seed streams a single peer can pin open. */
19
+ const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
20
+ /**
21
+ * Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
22
+ *
23
+ * Guards every parse site against malformed input: a buffer too short to hold
24
+ * the prefix, a declared length exceeding `maxLength`, and a declared length
25
+ * exceeding the bytes actually present. Returns a view (`subarray`, no copy) —
26
+ * the body is handed straight to `TextDecoder`.
27
+ */
28
+ export function decodeLengthPrefixedFrame(data, maxLength = MAX_SEED_SIZE) {
29
+ if (data.length < 4) {
30
+ throw new Error(`Seed frame too short: ${data.length} bytes, need ≥4 for length prefix`);
31
+ }
32
+ // Pass the full (buffer, byteOffset, byteLength) triple so the read is correct
33
+ // even for a non-zero-offset view, not just the fresh zero-offset arrays
34
+ // current callers pass.
35
+ const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0, false);
36
+ const available = data.length - 4;
37
+ if (length > maxLength) {
38
+ throw new Error(`Seed frame declares length ${length} exceeding max ${maxLength}`);
39
+ }
40
+ if (length > available) {
41
+ throw new Error(`Seed frame declares length ${length} but only ${available} body bytes present`);
42
+ }
43
+ return data.subarray(4, 4 + length);
44
+ }
45
+ /**
46
+ * Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
47
+ *
48
+ * An Ed25519 PeerId is an identity multihash of the public key, so
49
+ * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
50
+ * base64url form matches the `OwnerKey.Key` representation (and
51
+ * `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
52
+ * id, a missing embedded key, or any parse failure — callers treat null as
53
+ * "not an owner" rather than throwing.
54
+ */
55
+ export function ed25519PublicKeyB64FromPeerId(peerId) {
56
+ try {
57
+ const parsed = peerIdFromString(peerId);
58
+ if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
59
+ return null;
60
+ }
61
+ return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Canonical byte representation of the authenticated seed fields.
69
+ *
70
+ * Routes both the creator (`createSeed`) and the verifier
71
+ * (`validateSeedSignature`) through one builder so the signed bytes are
72
+ * identical regardless of key insertion order. `canonicalJson` sorts keys and
73
+ * drops `undefined`, so the signed payload is exactly `{ partyId, peers }` —
74
+ * the fields the producer actually emits.
75
+ */
76
+ export function canonicalSeedPayload(seed) {
77
+ return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
78
+ }
11
79
  /**
12
80
  * SeedBootstrapService handles control network seed generation and delivery.
13
81
  *
14
82
  * Seeds solve the cold-start problem: new nodes need control data to validate
15
83
  * connections, but can't get data without connecting first. Seeds pre-populate
16
- * the new node's cache with peer information and optionally transactions.
84
+ * the new node's cache with peer information.
17
85
  */
18
86
  export class SeedBootstrapService {
19
87
  constructor(config) {
20
88
  this.libp2pNode = null;
21
89
  this.controlDatabase = null;
90
+ /** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
91
+ this.activeStreams = 0;
22
92
  this.eventCallbacks = {};
23
93
  this.config = config;
94
+ this.trustPolicy = config.trustPolicy ?? dbAnchoredTrustPolicy();
95
+ this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
96
+ this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
24
97
  // Derive public key from private key if not provided
25
- if (config.authorityPrivateKey && !config.authorityPublicKey) {
26
- this.authorityPublicKey = getPublicKey(config.authorityPrivateKey, 'ed25519', 'base64url', 'base64url');
98
+ if (config.ownerPrivateKey && !config.ownerPublicKey) {
99
+ this.ownerPublicKey = getPublicKey(config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url');
27
100
  }
28
101
  else {
29
- this.authorityPublicKey = config.authorityPublicKey ?? null;
102
+ this.ownerPublicKey = config.ownerPublicKey ?? null;
30
103
  }
31
104
  log('SeedBootstrapService created for party: %s', config.partyId);
32
105
  }
@@ -38,74 +111,285 @@ export class SeedBootstrapService {
38
111
  this.eventCallbacks = callbacks;
39
112
  }
40
113
  /**
41
- * Initialize the service with libp2p node and control database
114
+ * Whether this service holds an owner private key, i.e. can produce the
115
+ * owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
116
+ * and re-authorizations. A seed-listener-only service (`enableSeedListener`,
117
+ * no owner key) returns false: it can receive/apply seeds but cannot author
118
+ * or re-issue owner writes. Used by the write-while-alone re-replication
119
+ * drain to skip owner work on a non-owner node.
120
+ */
121
+ canAuthorize() {
122
+ return !!this.config.ownerPrivateKey;
123
+ }
124
+ /**
125
+ * Initialize the service with libp2p node and control database.
126
+ *
127
+ * `registerHandler` (default true) gates registration of the shared inbound
128
+ * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
129
+ * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
130
+ * it on. The throwaway temp services CadreNode builds in `applySeed` /
131
+ * `dialInvite` pass `false`: they only need the stored `libp2pNode` /
132
+ * `controlDatabase` for dialing and known-key lookup, and must NOT bind a
133
+ * discarded closure to the shared node (a handler leak, and a second
134
+ * `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
42
135
  */
43
- initialize(libp2pNode, controlDatabase) {
136
+ initialize(libp2pNode, controlDatabase, options) {
44
137
  this.libp2pNode = libp2pNode;
45
138
  this.controlDatabase = controlDatabase;
46
- // Register the seed protocol handler
47
- this.registerProtocolHandler();
139
+ // Register the seed protocol handler unless the caller opted out (temp services).
140
+ if (options?.registerHandler ?? true) {
141
+ this.registerProtocolHandler();
142
+ }
48
143
  log('SeedBootstrapService initialized');
49
144
  }
50
145
  /**
51
146
  * Authorize a new peer to join the cadre.
52
- * Signs the peer ID with the authority key and inserts into CadrePeer table.
147
+ * Signs the peer ID with the owner key and inserts into CadrePeer table.
148
+ *
149
+ * The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
150
+ * caller-supplied key, the binding is enforced by construction — `PublicKey` is
151
+ * DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
152
+ * `PublicKey`, and such a row can never be self-updated (it has no key to
153
+ * verify against), which is correct. The row is inserted with a fresh
154
+ * `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
155
+ * the peer's self-signature, so the peer must self-publish (see
156
+ * {@link CadreNode.registerSelf}) before the row resolves.
53
157
  */
54
158
  async authorizePeer(options) {
55
159
  const { peerId, multiaddrs } = options;
56
- if (!this.config.authorityPrivateKey) {
57
- throw new Error('Authority private key required to authorize peers');
160
+ log('Authorizing peer: %s', peerId);
161
+ // CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
162
+ const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
163
+ await this.insertCadrePeerRow({
164
+ peerId,
165
+ publicKey: ed25519PublicKeyB64FromPeerId(peerId),
166
+ multiaddr: multiaddrStr,
167
+ updatedAt: Date.now(),
168
+ sig: null,
169
+ });
170
+ log('Peer %s authorized successfully', peerId);
171
+ }
172
+ /**
173
+ * Owner-signed INSERT of this node's OWN self-signed address record.
174
+ *
175
+ * Used by {@link CadreNode.registerSelf} when the node is not yet a member and
176
+ * is its own owner (it holds the owner key): the row is owner-signed
177
+ * (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
178
+ * immediately without a follow-up self-update.
179
+ */
180
+ async insertSelfPeerRecord(record) {
181
+ await this.insertCadrePeerRow({
182
+ peerId: record.peerId,
183
+ publicKey: record.publicKey,
184
+ multiaddr: record.addrs.join(','),
185
+ updatedAt: record.updatedAt,
186
+ sig: record.sig,
187
+ });
188
+ }
189
+ /**
190
+ * Shared owner-signed `CadrePeer` INSERT. Mints a fresh single-use `StampId`
191
+ * and signs the voucher digest `digest(peerId, stampId)` ({@link cadrePeerVoucherDigest})
192
+ * with the owner key (satisfying `AuthorizedInsert`), then writes the full record
193
+ * row with the vouching (owner, signature) persisted into VouchOwner/VouchSig.
194
+ * The owner signature does NOT cover the address columns — those are vouched only
195
+ * as far as the owner asserts them, and a peer's own `Sig` (when present) is what
196
+ * makes the row resolvable.
197
+ */
198
+ async insertCadrePeerRow(row) {
199
+ if (!this.controlDatabase) {
200
+ throw new Error('Control database not initialized');
58
201
  }
202
+ // Fresh single-use nonce; the voucher signs digest(peerId, stampId) so a captured
203
+ // insert can't be replayed (StampId is unique) and can't be repurposed as a delete
204
+ // (which signs a distinct 'remove'-scoped digest).
205
+ // NOTE: StampId uniqueness only blocks insert-replay while the row (or its StampId)
206
+ // is still present — a delete frees the StampId, so a captured authorized insert could
207
+ // be replayed to re-add a peer the owner had removed (a revocation bypass). Fully
208
+ // subsumed by the connection gater (ticket 6): with no outsider write there is no
209
+ // replay at all. If that gate ever proves insufficient, bind inserts to a persistent
210
+ // per-peer sequence / tombstone instead of a row-scoped nonce.
211
+ const stampId = generateStampId(row.peerId);
212
+ const signature = this.signDigest(cadrePeerVoucherDigest(row.peerId, stampId));
213
+ const db = this.controlDatabase.getDatabase();
214
+ // Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig)
215
+ // — identical to the context pair, which the AuthorizedInsert constraint binds — so a
216
+ // reader can later re-check the voucher against its node-local trusted-owner anchor.
217
+ await db.exec(`
218
+ insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
219
+ with context OwnerKey = ?, Signature = ?
220
+ values (?, ?, ?, ?, ?, ?, ?, ?)
221
+ `, [this.ownerPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, this.ownerPublicKey, signature]);
222
+ }
223
+ /**
224
+ * Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
225
+ *
226
+ * Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
227
+ * row is owner-signed (satisfying `DeviceToken.AuthorizedInsert`, which vouches
228
+ * membership exactly as `CadrePeer.AuthorizedInsert` does) AND carries the peer's
229
+ * own self-`Sig` over the token payload. The owner signature covers only the
230
+ * PeerId — it does NOT vouch the token contents; the peer's `Sig` (verified at
231
+ * resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
232
+ * resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
233
+ * when the node is its own owner.
234
+ */
235
+ async insertSelfDeviceToken(record) {
236
+ const signature = this.signPeerAuthorization(record.peerId);
59
237
  if (!this.controlDatabase) {
60
238
  throw new Error('Control database not initialized');
61
239
  }
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
240
  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
241
  await db.exec(`
71
- insert into CadreControl.CadrePeer (PeerId, Multiaddr)
72
- with context AuthorityKey = ?, Signature = ?
73
- values (?, ?)
74
- `, [this.authorityPublicKey, signature, peerId, multiaddrStr]);
75
- log('Peer %s authorized successfully', peerId);
242
+ insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig)
243
+ with context OwnerKey = ?, Signature = ?
244
+ values (?, ?, ?, ?, ?)
245
+ `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig]);
246
+ log('Device token inserted (owner-signed): %s', record.peerId);
247
+ }
248
+ /**
249
+ * Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
250
+ * invalidation). The `DeviceToken.AuthorizedInsert` constraint gates both insert
251
+ * AND delete on an owner signature over `digest(old.PeerId)`, so — like
252
+ * {@link removePeer} for `CadrePeer` — clearing a token requires the owner key.
253
+ */
254
+ async deleteDeviceToken(peerId) {
255
+ const signature = this.signPeerAuthorization(peerId);
256
+ if (!this.controlDatabase) {
257
+ throw new Error('Control database not initialized');
258
+ }
259
+ const db = this.controlDatabase.getDatabase();
260
+ await db.exec(`
261
+ delete from CadreControl.DeviceToken
262
+ with context OwnerKey = ?, Signature = ?
263
+ where PeerId = ?
264
+ `, [this.ownerPublicKey, signature, peerId]);
265
+ log('Device token removed (owner-signed): %s', peerId);
266
+ }
267
+ /**
268
+ * Sign a peer ID with the owner key for a `CadrePeer` / `DeviceToken`
269
+ * insert-or-delete. The signed bytes come from the shared
270
+ * {@link peerAuthorizationDigest} helper so the offline `cadre enroll register`
271
+ * verifier checks the exact same construction. Throws if no owner key is set.
272
+ */
273
+ signPeerAuthorization(peerId) {
274
+ return this.signDigest(peerAuthorizationDigest(peerId));
275
+ }
276
+ /**
277
+ * Return the configured owner private key, or throw if none is set. The single
278
+ * precondition gate for every owner-signed write. {@link removePeer} /
279
+ * {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
280
+ * they call this up front — otherwise a keyless service would either surface the
281
+ * wrong "Control database not initialized" error or, worse, silently no-op when the
282
+ * target row is absent (the early `stampId === null` return) instead of rejecting.
283
+ */
284
+ requireOwnerPrivateKey() {
285
+ if (!this.config.ownerPrivateKey) {
286
+ throw new Error('Owner private key required to authorize peers');
287
+ }
288
+ return this.config.ownerPrivateKey;
76
289
  }
77
290
  /**
78
- * Remove a peer from the cadre by authority signature.
291
+ * Sign a base64url digest with the owner key (ed25519). The single place the
292
+ * owner private key is applied; callers pass the canonical digest for the specific
293
+ * action ({@link cadrePeerVoucherDigest} / {@link cadrePeerRemoveDigest} /
294
+ * {@link peerAuthorizationDigest}). Throws if no owner key is set.
295
+ */
296
+ signDigest(digestB64url) {
297
+ return sign(digestB64url, this.requireOwnerPrivateKey(), 'ed25519', 'base64url', 'base64url', 'base64url');
298
+ }
299
+ /**
300
+ * Remove a peer from the cadre by owner signature.
79
301
  *
80
- * The constraint over CadrePeer's `check on insert, delete` validates a
81
- * signature over `digest(old.PeerId, 'sha256', 'utf8')` by an authority
82
- * key. We use the same digest pattern as authorizePeer.
302
+ * The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
303
+ * signature over the DISTINCT 'remove'-scoped digest `digest(old.PeerId, old.StampId,
304
+ * 'remove')` ({@link cadrePeerRemoveDigest}) by an owner key — deliberately NOT the
305
+ * insert voucher digest, so the row's stored `VouchSig` can never be replayed to delete.
83
306
  */
84
307
  async removePeer(peerId) {
85
- if (!this.config.authorityPrivateKey) {
86
- throw new Error('Authority private key required to remove peers');
87
- }
308
+ // Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
309
+ // remove digest, and this precedence (owner key, then control DB) is what the
310
+ // unit contract asserts.
311
+ this.requireOwnerPrivateKey();
88
312
  if (!this.controlDatabase) {
89
313
  throw new Error('Control database not initialized');
90
314
  }
315
+ // Delete is authorized by a signature over a DISTINCT 'remove'-scoped digest bound to
316
+ // the row's CURRENT StampId — so the row's stored voucher (a signature over the
317
+ // voucher digest) can never be replayed to authorize a delete.
318
+ const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
319
+ if (stampId === null) {
320
+ log('removePeer: no CadrePeer row for %s (already absent)', peerId);
321
+ return;
322
+ }
323
+ const signature = this.signDigest(cadrePeerRemoveDigest(peerId, stampId));
91
324
  log('Removing peer: %s', peerId);
92
- const peerIdDigest = digest(peerId, 'sha256', 'utf8', 'base64url');
93
- const signature = sign(peerIdDigest, this.config.authorityPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
94
325
  const db = this.controlDatabase.getDatabase();
95
326
  await db.exec(`
96
327
  delete from CadreControl.CadrePeer
97
- with context AuthorityKey = ?, Signature = ?
328
+ with context OwnerKey = ?, Signature = ?
98
329
  where PeerId = ?
99
- `, [this.authorityPublicKey, signature, peerId]);
330
+ `, [this.ownerPublicKey, signature, peerId]);
100
331
  log('Peer %s removed successfully', peerId);
101
332
  }
333
+ /**
334
+ * Owner "re-touch" of an existing `CadrePeer` membership row: bump
335
+ * `UpdatedAt` under the owner branch of `CadrePeer.AuthorizedUpdate` (a
336
+ * signature over `digest(peerId)` — the same construction {@link authorizePeer}
337
+ * uses) so the row is re-emitted as a fresh, broadcasting transaction.
338
+ *
339
+ * This is the write-while-alone re-replication primitive
340
+ * (`control-write-ensure-replicated`): a membership row that committed
341
+ * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
342
+ * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original
343
+ * INSERT) because the row already exists locally; a re-INSERT would hit the
344
+ * `PeerId` PK. Only the freshness stamp changes — `PublicKey` / `Multiaddr` /
345
+ * `Sig` are left intact — so it is safe over a row whose peer has not
346
+ * self-published (`Sig` null); the caller must skip a row that already carries a
347
+ * self-`Sig` (that row is the owning peer's to refresh, and bumping `UpdatedAt`
348
+ * without re-signing would invalidate its self-signature).
349
+ *
350
+ * @param peerId - the membership row to re-touch.
351
+ * @param updatedAt - the strictly-increasing freshness stamp to write.
352
+ * @throws if no owner private key is configured (a non-owner cannot
353
+ * re-sign another peer's row) or the control database is not initialized.
354
+ */
355
+ async reauthorizePeer(peerId, updatedAt) {
356
+ // Fail fast on a keyless service before any DB read (see removePeer): a non-owner
357
+ // cannot re-sign the voucher, and must not silently no-op on an absent row.
358
+ this.requireOwnerPrivateKey();
359
+ if (!this.controlDatabase) {
360
+ throw new Error('Control database not initialized');
361
+ }
362
+ // The owner branch of AuthorizedUpdate verifies a voucher over digest(PeerId,
363
+ // StampId) and re-binds VouchOwner/VouchSig. Sign over the row's CURRENT StampId
364
+ // (unchanged by this re-touch) and re-set the voucher columns so the branch passes.
365
+ // NOTE: this rebinds VouchOwner to THIS node's owner key. Benign today — the
366
+ // write-while-alone drain only re-touches rows this node itself authored. If a future
367
+ // path lets one owner re-touch a row a DIFFERENT owner vouched, the voucher
368
+ // silently flips to this owner; that only matters once ticket-4's predicate checks
369
+ // VouchOwner against a node-local anchor, and only if the two owners differ in
370
+ // anchor membership.
371
+ const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
372
+ if (stampId === null) {
373
+ log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
374
+ return;
375
+ }
376
+ const signature = this.signDigest(cadrePeerVoucherDigest(peerId, stampId));
377
+ const db = this.controlDatabase.getDatabase();
378
+ await db.exec(`
379
+ update CadreControl.CadrePeer
380
+ with context OwnerKey = ?, Signature = ?
381
+ set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
382
+ where PeerId = ?
383
+ `, [this.ownerPublicKey, signature, updatedAt, this.ownerPublicKey, signature, peerId]);
384
+ log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
385
+ }
102
386
  /**
103
387
  * Create a seed from the current control network state.
104
- * The seed contains peer information and is signed by an authority.
388
+ * The seed contains peer information and is signed by an owner.
105
389
  */
106
390
  async createSeed() {
107
- if (!this.config.authorityPrivateKey || !this.authorityPublicKey) {
108
- throw new Error('Authority key required to create seeds');
391
+ if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
392
+ throw new Error('Owner key required to create seeds');
109
393
  }
110
394
  if (!this.controlDatabase || !this.libp2pNode) {
111
395
  throw new Error('Service not initialized');
@@ -118,23 +402,34 @@ export class SeedBootstrapService {
118
402
  partyId: this.config.partyId,
119
403
  peers,
120
404
  };
121
- // Sign the seed
122
- const seedJson = JSON.stringify(seedData);
123
- const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url');
124
- const signature = sign(seedDigest, this.config.authorityPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
405
+ // Sign the seed over its canonical byte representation
406
+ const seedJson = canonicalSeedPayload(seedData);
407
+ const seedDigest = digest([seedJson], 'sha256', 'base64url');
408
+ const signature = sign(seedDigest, this.config.ownerPrivateKey, 'ed25519', 'base64url', 'base64url', 'base64url');
125
409
  const seed = {
126
410
  ...seedData,
127
411
  signature,
128
- signerKey: this.authorityPublicKey,
412
+ signerKey: this.ownerPublicKey,
129
413
  };
130
414
  log('Created seed with %d peers', peers.length);
131
415
  return seed;
132
416
  }
133
417
  /**
134
418
  * Apply a seed to populate the peer cache and enable connections.
135
- * Validates the seed signature before applying.
419
+ *
420
+ * Validates the seed signature, then evaluates a trust anchor for the
421
+ * `signerKey` that does NOT come from the seed body: the receiver's
422
+ * `OwnerKey` table (DB-anchored), optionally augmented by pinned keys or
423
+ * TOFU via the configured/overriding `SeedTrustPolicy`. A forged
424
+ * self-asserting seed — one that merely lists its own signer as an owner
425
+ * peer — no longer passes.
426
+ *
427
+ * @param seed - The seed to apply (already transport-decoded).
428
+ * @param options.trustPolicy - Per-call policy override (e.g. a
429
+ * `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
430
+ * service-configured default for this seed only.
136
431
  */
137
- async applySeed(seed) {
432
+ async applySeed(seed, options) {
138
433
  if (!this.libp2pNode) {
139
434
  return { success: false, peersAdded: 0, error: 'Service not initialized' };
140
435
  }
@@ -143,10 +438,24 @@ export class SeedBootstrapService {
143
438
  if (!this.validateSeedSignature(seed)) {
144
439
  return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
145
440
  }
146
- // Verify the signer's key matches an authority peer's public key
147
- const signerIsAuthority = seed.peers.some(p => p.isAuthority && p.publicKey === seed.signerKey);
148
- if (!signerIsAuthority) {
149
- return { success: false, peersAdded: 0, error: 'Signer key does not match any authority peer' };
441
+ // Evaluate the trust anchor for the signer key. The known-owner set is
442
+ // sourced from the receiver's control DB, never from the seed itself; a
443
+ // cold-start node with no DB and no override therefore sees an empty set.
444
+ const knownOwnerKeys = this.controlDatabase
445
+ ? await this.controlDatabase.getOwnerKeys()
446
+ : new Set();
447
+ const policy = options?.trustPolicy ?? this.trustPolicy;
448
+ const decision = await policy.evaluate({
449
+ partyId: seed.partyId,
450
+ signerKey: seed.signerKey,
451
+ knownOwnerKeys,
452
+ });
453
+ if (!decision.trusted) {
454
+ return {
455
+ success: false,
456
+ peersAdded: 0,
457
+ error: decision.reason ?? 'Signer key not trusted by trust policy',
458
+ };
150
459
  }
151
460
  let peersAdded = 0;
152
461
  // Add peers to the peer store
@@ -167,12 +476,12 @@ export class SeedBootstrapService {
167
476
  log('Failed to add peer %s: %o', peer.peerId, error);
168
477
  }
169
478
  }
170
- // Dial authority peers to establish connections
171
- for (const peer of seed.peers.filter(p => p.isAuthority)) {
479
+ // Dial owner peers to establish connections
480
+ for (const peer of seed.peers.filter(p => p.isOwner)) {
172
481
  try {
173
482
  if (peer.multiaddrs.length > 0) {
174
483
  const addr = multiaddr(peer.multiaddrs[0]);
175
- log('Dialing authority peer: %s', peer.peerId);
484
+ log('Dialing owner peer: %s', peer.peerId);
176
485
  await this.libp2pNode.dial(addr);
177
486
  }
178
487
  }
@@ -212,20 +521,14 @@ export class SeedBootstrapService {
212
521
  const rawStream = await this.libp2pNode.dialProtocol(addr, SEED_PROTOCOL);
213
522
  const stream = rawStream;
214
523
  try {
215
- // Send the seed message
524
+ // Send the seed message as one length-prefixed frame (shared writer).
216
525
  const message = {
217
526
  partyId: seed.partyId,
218
527
  peers: seed.peers,
219
- transactions: seed.transactions,
220
528
  signature: seed.signature,
221
529
  signerKey: seed.signerKey,
222
530
  };
223
- const messageBytes = new TextEncoder().encode(JSON.stringify(message));
224
- // Write length-prefixed message using libp2p 3.x send() API
225
- const lengthBytes = new Uint8Array(4);
226
- new DataView(lengthBytes.buffer).setUint32(0, messageBytes.length, false);
227
- stream.send(lengthBytes);
228
- stream.send(messageBytes);
531
+ writeFrame(stream, message);
229
532
  // In libp2p v3.x, close() closes the write end only (signals EOF),
230
533
  // while the read end remains open for receiving the ack.
231
534
  await stream.close();
@@ -242,8 +545,8 @@ export class SeedBootstrapService {
242
545
  offset += chunk.length;
243
546
  }
244
547
  // Parse length-prefixed response
245
- const responseLength = new DataView(responseData.buffer, responseData.byteOffset).getUint32(0, false);
246
- const responseJson = new TextDecoder().decode(responseData.slice(4, 4 + responseLength));
548
+ const responseBody = decodeLengthPrefixedFrame(responseData);
549
+ const responseJson = new TextDecoder().decode(responseBody);
247
550
  const ack = JSON.parse(responseJson);
248
551
  log('Seed delivery response: accepted=%s', ack.accepted);
249
552
  return ack;
@@ -271,14 +574,11 @@ export class SeedBootstrapService {
271
574
  */
272
575
  validateSeedSignature(seed) {
273
576
  try {
274
- // Reconstruct the signed data (seed without signature fields)
275
- const seedData = {
276
- partyId: seed.partyId,
277
- peers: seed.peers,
278
- ...(seed.transactions ? { transactions: seed.transactions } : {}),
279
- };
280
- const seedJson = JSON.stringify(seedData);
281
- const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url');
577
+ // Reconstruct the signed bytes via the shared canonical payload builder so
578
+ // verification is independent of key order. The payload is the fixed
579
+ // `{ partyId, peers }` the producer emits.
580
+ const seedJson = canonicalSeedPayload(seed);
581
+ const seedDigest = digest([seedJson], 'sha256', 'base64url');
282
582
  return verify(seedDigest, seed.signature, seed.signerKey, 'ed25519', 'base64url', 'base64url', 'base64url');
283
583
  }
284
584
  catch (error) {
@@ -288,120 +588,135 @@ export class SeedBootstrapService {
288
588
  }
289
589
  /**
290
590
  * Query peers from the control database.
591
+ *
592
+ * Owner identity is sourced from the `OwnerKey` table, not from the
593
+ * transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
594
+ * multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
595
+ * peer is an owner iff that derived key is in the `OwnerKey` set.
596
+ * This makes any owner node markable — not just the local one — and ties
597
+ * `isOwner` to the control table rather than to `peerId === self`.
291
598
  */
292
599
  async queryPeers() {
293
600
  if (!this.controlDatabase) {
294
601
  return [];
295
602
  }
603
+ const ownerKeys = await this.controlDatabase.getOwnerKeys();
296
604
  const db = this.controlDatabase.getDatabase();
297
605
  const peers = [];
298
- // First, get all authority keys
299
- const authorityKeys = new Set();
300
- for await (const row of db.eval('select Key from CadreControl.AuthorityKey')) {
301
- authorityKeys.add(row.Key);
302
- }
303
606
  // Query CadrePeer table
304
607
  for await (const row of db.eval('select PeerId, Multiaddr from CadreControl.CadrePeer')) {
305
608
  const peerId = row.PeerId;
306
609
  const multiaddr = row.Multiaddr;
307
- // Mark peer as authority if it matches the service's own peer ID
308
- const isAuthority = peerId === this.libp2pNode?.peerId.toString();
610
+ // Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
611
+ // unparsable id yields null and is treated as a non-owner rather than
612
+ // failing the whole seed creation.
613
+ const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
614
+ const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
309
615
  peers.push({
310
616
  peerId,
311
617
  multiaddrs: multiaddr ? multiaddr.split(',') : [],
312
- isAuthority,
313
- ...(isAuthority && this.authorityPublicKey ? { publicKey: this.authorityPublicKey } : {}),
618
+ isOwner,
619
+ ...(isOwner ? { publicKey: pubKeyB64 } : {}),
314
620
  });
315
621
  }
316
622
  return peers;
317
623
  }
318
624
  /**
319
- * Register the seed protocol handler.
625
+ * Register the seed protocol handler. The inbound closure just delegates to
626
+ * {@link handleSeedStream} — extracted as a method so it has a unit-test seam
627
+ * (mirroring wake's `handleStream`) the inline closure never had.
320
628
  */
321
629
  registerProtocolHandler() {
322
630
  if (!this.libp2pNode)
323
631
  return;
324
- this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream, rawConnection) => {
325
- const stream = rawStream;
632
+ void this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream, rawConnection) => {
326
633
  const remotePeerId = rawConnection.remotePeer.toString();
327
- log('Incoming seed delivery from: %s', remotePeerId);
634
+ await this.handleSeedStream(rawStream, remotePeerId);
635
+ });
636
+ log('Registered seed protocol handler: %s', SEED_PROTOCOL);
637
+ }
638
+ /**
639
+ * Read one inbound seed frame, apply it, and write the ack.
640
+ *
641
+ * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
642
+ * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
643
+ * that never half-closes is aborted inside `readStreamToEnd`), and the existing
644
+ * malformed/oversized-frame guard — all reported as a non-accepting
645
+ * {@link SeedAckMessage} rather than a dropped/hung stream.
646
+ */
647
+ async handleSeedStream(stream, remotePeerId) {
648
+ log('Incoming seed delivery from: %s', remotePeerId);
649
+ if (this.activeStreams >= this.maxConcurrentSeeds) {
650
+ log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
651
+ const ack = { accepted: false, reason: 'Too many concurrent seed deliveries' };
328
652
  try {
329
- // Read the seed message (stream is AsyncIterable in libp2p 3.x)
330
- const chunks = [];
331
- let totalLength = 0;
332
- for await (const chunk of stream) {
333
- const bytes = chunk instanceof Uint8Array ? chunk : chunk.subarray();
334
- chunks.push(bytes);
335
- totalLength += bytes.length;
336
- if (totalLength > MAX_SEED_SIZE) {
337
- throw new Error('Seed message too large');
338
- }
339
- }
340
- const data = new Uint8Array(totalLength);
341
- let offset = 0;
342
- for (const chunk of chunks) {
343
- data.set(chunk, offset);
344
- offset += chunk.length;
345
- }
346
- // Parse length-prefixed message
347
- const messageLength = new DataView(data.buffer, data.byteOffset).getUint32(0, false);
348
- const messageJson = new TextDecoder().decode(data.slice(4, 4 + messageLength));
349
- const message = JSON.parse(messageJson);
350
- // Emit seed received event
351
- this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
352
- // Convert to seed and apply
353
- const seed = {
354
- partyId: message.partyId,
355
- peers: message.peers,
356
- transactions: message.transactions,
357
- signature: message.signature,
358
- signerKey: message.signerKey,
359
- };
360
- const result = await this.applySeed(seed);
361
- // Emit appropriate event based on result
362
- if (result.success) {
363
- this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
364
- }
365
- else {
366
- this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
367
- }
368
- // Send acknowledgment using libp2p 3.x send() API
369
- const ack = {
370
- accepted: result.success,
371
- reason: result.error,
372
- };
373
- const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
374
- const lengthBytes = new Uint8Array(4);
375
- new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
376
- stream.send(lengthBytes);
377
- stream.send(ackBytes);
653
+ writeFrame(stream, ack);
378
654
  }
379
- catch (error) {
380
- log('Error handling seed delivery: %o', error);
381
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
382
- // Emit error event
383
- this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
384
- // Send error acknowledgment
385
- const ack = {
386
- accepted: false,
387
- reason: errorMessage,
388
- };
389
- const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
390
- const lengthBytes = new Uint8Array(4);
391
- new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
392
- try {
393
- stream.send(lengthBytes);
394
- stream.send(ackBytes);
395
- }
396
- catch {
397
- // Ignore send errors
398
- }
655
+ catch {
656
+ // Ignore send errors on the reject path.
399
657
  }
400
- finally {
658
+ try {
401
659
  await stream.close();
402
660
  }
403
- });
404
- log('Registered seed protocol handler: %s', SEED_PROTOCOL);
661
+ catch {
662
+ // Ignore close errors.
663
+ }
664
+ return;
665
+ }
666
+ this.activeStreams++;
667
+ try {
668
+ // Read the seed frame to EOF (bounded + size-capped), then decode it.
669
+ const data = await readStreamToEnd(stream, {
670
+ maxBytes: MAX_SEED_SIZE,
671
+ timeoutMs: this.seedReadTimeoutMs,
672
+ label: 'Seed',
673
+ });
674
+ const messageBody = decodeLengthPrefixedFrame(data);
675
+ const messageJson = new TextDecoder().decode(messageBody);
676
+ const message = JSON.parse(messageJson);
677
+ // Emit seed received event
678
+ this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
679
+ // Convert to seed and apply
680
+ const seed = {
681
+ partyId: message.partyId,
682
+ peers: message.peers,
683
+ signature: message.signature,
684
+ signerKey: message.signerKey,
685
+ };
686
+ const result = await this.applySeed(seed);
687
+ // Emit appropriate event based on result
688
+ if (result.success) {
689
+ this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
690
+ }
691
+ else {
692
+ this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
693
+ }
694
+ const ack = { accepted: result.success, reason: result.error };
695
+ writeFrame(stream, ack);
696
+ }
697
+ catch (error) {
698
+ log('Error handling seed delivery: %o', error);
699
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
700
+ // Emit error event
701
+ this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
702
+ // Send error acknowledgment (best-effort on the still-open write side).
703
+ const ack = { accepted: false, reason: errorMessage };
704
+ try {
705
+ writeFrame(stream, ack);
706
+ }
707
+ catch {
708
+ // Ignore send errors
709
+ }
710
+ }
711
+ finally {
712
+ this.activeStreams--;
713
+ try {
714
+ await stream.close();
715
+ }
716
+ catch {
717
+ // Ignore close errors.
718
+ }
719
+ }
405
720
  }
406
721
  /**
407
722
  * Shutdown the service.
@@ -461,29 +776,35 @@ export class SeedBootstrapService {
461
776
  // configured (typically by `@serfab/cadre-host`'s NatService), it takes
462
777
  // priority — it may substitute a DDNS hostname and externally-mapped
463
778
  // port for the raw LAN multiaddrs libp2p reports.
464
- let authorityAddrs;
779
+ let ownerAddrs;
465
780
  if (this.config.inviteAddressResolver) {
466
781
  try {
467
- authorityAddrs = await this.config.inviteAddressResolver();
782
+ ownerAddrs = await this.config.inviteAddressResolver();
468
783
  }
469
784
  catch (err) {
470
785
  log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
471
- authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
786
+ ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
472
787
  }
473
788
  }
474
789
  else {
475
- authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
790
+ ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
476
791
  }
792
+ // Carry the cadre's owner keys out-of-band so a cold-start invitee can
793
+ // pin the trusted owner set before applying any seed.
794
+ const ownerKeys = this.controlDatabase
795
+ ? Array.from(await this.controlDatabase.getOwnerKeys())
796
+ : [];
477
797
  const now = Date.now();
478
798
  const invite = {
479
799
  partyId: this.config.partyId,
480
- authorityAddrs,
800
+ ownerAddrs,
801
+ ownerKeys: ownerKeys.length ? ownerKeys : undefined,
481
802
  token,
482
803
  createdAt: now,
483
804
  expiresAt: expiresIn ? now + expiresIn : undefined,
484
805
  };
485
806
  const encodedInvite = this.encodeInvite(invite);
486
- log('Invite created with %d authority addresses', authorityAddrs.length);
807
+ log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
487
808
  return { invite, encodedInvite };
488
809
  }
489
810
  /**
@@ -560,11 +881,11 @@ export class SeedBootstrapService {
560
881
  return JSON.parse(json);
561
882
  }
562
883
  /**
563
- * Dial an authority from an invite.
564
- * Use this on a phone after receiving an invite to connect to the authority.
884
+ * Dial an owner from an invite.
885
+ * Use this on a phone after receiving an invite to connect to the owner.
565
886
  *
566
887
  * @param invite - The invite received out-of-band
567
- * @returns Connection to the authority
888
+ * @returns Connection to the owner
568
889
  */
569
890
  async dialInvite(invite) {
570
891
  if (!this.libp2pNode) {
@@ -574,14 +895,14 @@ export class SeedBootstrapService {
574
895
  if (invite.expiresAt && Date.now() > invite.expiresAt) {
575
896
  throw new Error('Invite has expired');
576
897
  }
577
- log('Dialing invite authority with %d addresses', invite.authorityAddrs.length);
578
- // Try each authority address until one succeeds
898
+ log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
899
+ // Try each owner address until one succeeds
579
900
  let lastError = null;
580
- for (const addrStr of invite.authorityAddrs) {
901
+ for (const addrStr of invite.ownerAddrs) {
581
902
  try {
582
903
  const addr = multiaddr(addrStr);
583
904
  await this.libp2pNode.dial(addr);
584
- log('Connected to authority at: %s', addrStr);
905
+ log('Connected to owner at: %s', addrStr);
585
906
  return;
586
907
  }
587
908
  catch (error) {
@@ -589,7 +910,7 @@ export class SeedBootstrapService {
589
910
  log('Failed to dial %s: %o', addrStr, error);
590
911
  }
591
912
  }
592
- throw lastError ?? new Error('No authority addresses available');
913
+ throw lastError ?? new Error('No owner addresses available');
593
914
  }
594
915
  }
595
916
  //# sourceMappingURL=seed-bootstrap.js.map