@serfab/cadre-core 0.9.0 → 0.10.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 (162) hide show
  1. package/README.md +4 -2
  2. package/dist/bootstrap-peer-store-file.d.ts +13 -0
  3. package/dist/bootstrap-peer-store-file.js +42 -0
  4. package/dist/bootstrap-peer-store-file.js.map +1 -0
  5. package/dist/bootstrap-peer-store.d.ts +73 -0
  6. package/dist/bootstrap-peer-store.js +128 -0
  7. package/dist/bootstrap-peer-store.js.map +1 -0
  8. package/dist/cadre-node.d.ts +918 -53
  9. package/dist/cadre-node.js +1843 -190
  10. package/dist/cadre-node.js.map +1 -1
  11. package/dist/control-authorization.d.ts +93 -0
  12. package/dist/control-authorization.js +57 -0
  13. package/dist/control-authorization.js.map +1 -0
  14. package/dist/control-database.d.ts +990 -50
  15. package/dist/control-database.js +1459 -108
  16. package/dist/control-database.js.map +1 -1
  17. package/dist/control-formation-recorder.d.ts +89 -10
  18. package/dist/control-formation-recorder.js +129 -14
  19. package/dist/control-formation-recorder.js.map +1 -1
  20. package/dist/control-schema.d.ts +1 -1
  21. package/dist/control-schema.js +774 -210
  22. package/dist/control-schema.js.map +1 -1
  23. package/dist/control-stream.d.ts +38 -4
  24. package/dist/control-stream.js +70 -4
  25. package/dist/control-stream.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +151 -0
  27. package/dist/control-write-retry.js +411 -0
  28. package/dist/control-write-retry.js.map +1 -0
  29. package/dist/delegate-admission.d.ts +134 -0
  30. package/dist/delegate-admission.js +244 -0
  31. package/dist/delegate-admission.js.map +1 -0
  32. package/dist/device-token.d.ts +11 -11
  33. package/dist/device-token.js +14 -13
  34. package/dist/device-token.js.map +1 -1
  35. package/dist/ed25519-key.d.ts +26 -0
  36. package/dist/ed25519-key.js +56 -1
  37. package/dist/ed25519-key.js.map +1 -1
  38. package/dist/file-durable-slot.d.ts +20 -0
  39. package/dist/file-durable-slot.js +53 -0
  40. package/dist/file-durable-slot.js.map +1 -0
  41. package/dist/formation-approval.d.ts +157 -0
  42. package/dist/formation-approval.js +375 -0
  43. package/dist/formation-approval.js.map +1 -0
  44. package/dist/fs-atomic.d.ts +38 -0
  45. package/dist/fs-atomic.js +97 -0
  46. package/dist/fs-atomic.js.map +1 -0
  47. package/dist/identity-key.d.ts +49 -0
  48. package/dist/identity-key.js +81 -0
  49. package/dist/identity-key.js.map +1 -0
  50. package/dist/index.d.ts +16 -6
  51. package/dist/index.js +39 -5
  52. package/dist/index.js.map +1 -1
  53. package/dist/key-store-file.d.ts +2 -24
  54. package/dist/key-store-file.js +10 -77
  55. package/dist/key-store-file.js.map +1 -1
  56. package/dist/membership-connection-gater.d.ts +146 -0
  57. package/dist/membership-connection-gater.js +180 -0
  58. package/dist/membership-connection-gater.js.map +1 -0
  59. package/dist/node-local-snapshot.d.ts +109 -0
  60. package/dist/node-local-snapshot.js +182 -0
  61. package/dist/node-local-snapshot.js.map +1 -0
  62. package/dist/peer-authorization.d.ts +135 -18
  63. package/dist/peer-authorization.js +152 -21
  64. package/dist/peer-authorization.js.map +1 -1
  65. package/dist/peer-record.d.ts +21 -8
  66. package/dist/peer-record.js +26 -10
  67. package/dist/peer-record.js.map +1 -1
  68. package/dist/relay-addrs.d.ts +45 -0
  69. package/dist/relay-addrs.js +103 -0
  70. package/dist/relay-addrs.js.map +1 -0
  71. package/dist/relay-reservation.d.ts +266 -0
  72. package/dist/relay-reservation.js +613 -0
  73. package/dist/relay-reservation.js.map +1 -0
  74. package/dist/seed-bootstrap.d.ts +221 -56
  75. package/dist/seed-bootstrap.js +328 -188
  76. package/dist/seed-bootstrap.js.map +1 -1
  77. package/dist/seed-trust-policy.d.ts +47 -21
  78. package/dist/seed-trust-policy.js +24 -15
  79. package/dist/seed-trust-policy.js.map +1 -1
  80. package/dist/strand-addr-protocol.d.ts +42 -13
  81. package/dist/strand-addr-protocol.js +58 -52
  82. package/dist/strand-addr-protocol.js.map +1 -1
  83. package/dist/strand-backfill.d.ts +109 -0
  84. package/dist/strand-backfill.js +279 -0
  85. package/dist/strand-backfill.js.map +1 -0
  86. package/dist/strand-formation-manager.d.ts +38 -9
  87. package/dist/strand-formation-manager.js +120 -17
  88. package/dist/strand-formation-manager.js.map +1 -1
  89. package/dist/strand-formation-protocol.d.ts +103 -4
  90. package/dist/strand-formation-protocol.js +232 -4
  91. package/dist/strand-formation-protocol.js.map +1 -1
  92. package/dist/strand-instance-manager.d.ts +39 -1
  93. package/dist/strand-instance-manager.js +92 -14
  94. package/dist/strand-instance-manager.js.map +1 -1
  95. package/dist/strand-member-registry.d.ts +5 -5
  96. package/dist/strand-member-registry.js +18 -10
  97. package/dist/strand-member-registry.js.map +1 -1
  98. package/dist/strand-membership-writer.d.ts +482 -88
  99. package/dist/strand-membership-writer.js +757 -136
  100. package/dist/strand-membership-writer.js.map +1 -1
  101. package/dist/strand-solicitation.d.ts +99 -33
  102. package/dist/strand-solicitation.js +100 -52
  103. package/dist/strand-solicitation.js.map +1 -1
  104. package/dist/strand-transport-key.d.ts +40 -0
  105. package/dist/strand-transport-key.js +56 -0
  106. package/dist/strand-transport-key.js.map +1 -0
  107. package/dist/strand-wake-protocol.d.ts +9 -7
  108. package/dist/strand-wake-protocol.js +22 -46
  109. package/dist/strand-wake-protocol.js.map +1 -1
  110. package/dist/strand-watcher.d.ts +17 -2
  111. package/dist/strand-watcher.js +47 -3
  112. package/dist/strand-watcher.js.map +1 -1
  113. package/dist/trusted-owner-store-file.d.ts +12 -0
  114. package/dist/trusted-owner-store-file.js +39 -0
  115. package/dist/trusted-owner-store-file.js.map +1 -0
  116. package/dist/trusted-owner-store.d.ts +64 -0
  117. package/dist/trusted-owner-store.js +126 -0
  118. package/dist/trusted-owner-store.js.map +1 -0
  119. package/dist/types.d.ts +275 -20
  120. package/dist/types.js +13 -0
  121. package/dist/types.js.map +1 -1
  122. package/package.json +19 -8
  123. package/src/bootstrap-peer-store-file.ts +44 -0
  124. package/src/bootstrap-peer-store.ts +172 -0
  125. package/src/cadre-node.ts +1992 -202
  126. package/src/control-authorization.ts +114 -0
  127. package/src/control-database.ts +1704 -146
  128. package/src/control-formation-recorder.ts +172 -19
  129. package/src/control-schema.ts +786 -222
  130. package/src/control-stream.ts +81 -4
  131. package/src/control-write-retry.ts +473 -0
  132. package/src/delegate-admission.ts +302 -0
  133. package/src/device-token.ts +18 -13
  134. package/src/ed25519-key.ts +134 -69
  135. package/src/file-durable-slot.ts +61 -0
  136. package/src/formation-approval.ts +604 -0
  137. package/src/fs-atomic.ts +107 -0
  138. package/src/identity-key.ts +109 -0
  139. package/src/index.ts +136 -5
  140. package/src/key-store-file.ts +10 -81
  141. package/src/membership-connection-gater.ts +204 -0
  142. package/src/node-local-snapshot.ts +260 -0
  143. package/src/peer-authorization.ts +199 -21
  144. package/src/peer-record.ts +28 -10
  145. package/src/relay-addrs.ts +111 -0
  146. package/src/relay-reservation.ts +823 -0
  147. package/src/seed-bootstrap.ts +397 -197
  148. package/src/seed-trust-policy.ts +58 -25
  149. package/src/strand-addr-protocol.ts +84 -53
  150. package/src/strand-backfill.ts +366 -0
  151. package/src/strand-formation-manager.ts +145 -19
  152. package/src/strand-formation-protocol.ts +309 -8
  153. package/src/strand-instance-manager.ts +111 -14
  154. package/src/strand-member-registry.ts +18 -13
  155. package/src/strand-membership-writer.ts +1430 -640
  156. package/src/strand-solicitation.ts +155 -90
  157. package/src/strand-transport-key.ts +58 -0
  158. package/src/strand-wake-protocol.ts +29 -46
  159. package/src/strand-watcher.ts +66 -3
  160. package/src/trusted-owner-store-file.ts +41 -0
  161. package/src/trusted-owner-store.ts +171 -0
  162. package/src/types.ts +292 -21
@@ -2,9 +2,9 @@ import debug from 'debug';
2
2
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
3
  import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
4
  import type { Libp2p, Connection } from '@libp2p/interface';
5
- import { multiaddr } from '@multiformats/multiaddr';
5
+ import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
6
6
  import { peerIdFromString } from '@libp2p/peer-id';
7
- import { type ControlStream, writeFrame, readStreamToEnd } from './control-stream.js';
7
+ import { type ControlStream, writeFrame, withDeadline, exchangeFrame, readStreamToEnd } from './control-stream.js';
8
8
  import type {
9
9
  ControlNetworkSeed,
10
10
  SeedPeer,
@@ -18,16 +18,19 @@ import type {
18
18
  InviteResult,
19
19
  CadreInvite,
20
20
  PeerAddressRecord,
21
- DeviceTokenRecord
21
+ DeviceTokenRecord,
22
+ RevocationRow
22
23
  } from './types.js';
23
24
  import type { ControlDatabase } from './control-database.js';
24
25
  import { generateStampId } from './control-database.js';
25
26
  import { canonicalJson } from './canonical-json.js';
26
- import { peerAuthorizationDigest, cadrePeerVoucherDigest, cadrePeerRemoveDigest } from './peer-authorization.js';
27
+ import { deviceTokenAddDigest } from './peer-authorization.js';
27
28
  import {
28
29
  type SeedTrustPolicy,
29
- dbAnchoredTrustPolicy,
30
+ type SeedTrustDecision,
31
+ anchoredTrustPolicy,
30
32
  } from './seed-trust-policy.js';
33
+ import type { TrustedOwnerStore } from './trusted-owner-store.js';
31
34
 
32
35
  const log = debug('sereus:cadre:seed-bootstrap');
33
36
 
@@ -43,6 +46,9 @@ const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
43
46
  /** Default cap on concurrent inbound seed streams a single peer can pin open. */
44
47
  const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
45
48
 
49
+ /** Default time the sender waits for a seed delivery (dial + ack read) before aborting (ms). */
50
+ const DEFAULT_SEED_DELIVER_TIMEOUT_MS = 10_000;
51
+
46
52
  /**
47
53
  * Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
48
54
  *
@@ -106,6 +112,14 @@ export function canonicalSeedPayload(
106
112
  return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
107
113
  }
108
114
 
115
+ /**
116
+ * An {@link ApplySeedResult} for a seed refused before the owner-dial loop ran,
117
+ * so the dial counters read zero rather than being absent.
118
+ */
119
+ function seedRejected(error: string): ApplySeedResult {
120
+ return { success: false, peersAdded: 0, error, ownerDialsAttempted: 0, ownerDialsFailed: 0 };
121
+ }
122
+
109
123
  /**
110
124
  * Configuration for the SeedBootstrapService
111
125
  */
@@ -125,9 +139,9 @@ export interface SeedBootstrapConfig {
125
139
  inviteAddressResolver?: () => Promise<string[]>;
126
140
  /**
127
141
  * Trust anchor for incoming seeds. Decides whether a signature-verified
128
- * `signerKey` should be trusted, against the receiver's known owner
129
- * keys (NOT the seed body). Defaults to `dbAnchoredTrustPolicy()`, which
130
- * rejects any signer not already in the `OwnerKey` table. An enrollment
142
+ * `signerKey` should be trusted, against the receiver's anchored owner
143
+ * keys (NOT the seed body). Defaults to `anchoredTrustPolicy()`, which
144
+ * rejects any signer not already in {@link trustedOwners}. An enrollment
131
145
  * caller can pass a per-seed override to `applySeed` instead.
132
146
  *
133
147
  * A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
@@ -135,6 +149,19 @@ export interface SeedBootstrapConfig {
135
149
  * since a network-delivered seed has no per-call override.
136
150
  */
137
151
  trustPolicy?: SeedTrustPolicy;
152
+ /**
153
+ * The node-local, NON-replicated trusted-owner anchor. Supplies
154
+ * `SeedTrustContext.knownOwnerKeys` for every {@link applySeed}, and receives
155
+ * a key accepted via a pin/TOFU (see `SeedTrustDecision.anchorAs`).
156
+ *
157
+ * Deliberately NOT `ControlDatabase.getOwnerKeys()`: the replicated
158
+ * `OwnerKey` table is pollutable — any connecting node can genesis-insert its
159
+ * own key and let it replicate — so a seed signed by a stranger's self-issued
160
+ * owner key would pass a table-anchored check. A `CadreNode` passes its
161
+ * `getTrustedOwnerStore()` here. Unset (e.g. a directly-constructed service in
162
+ * a test) means an EMPTY anchor: only a pinned/TOFU policy can accept a seed.
163
+ */
164
+ trustedOwners?: TrustedOwnerStore;
138
165
  /**
139
166
  * Time the inbound seed handler waits for the seed frame before aborting the
140
167
  * read (ms). Defaults to {@link DEFAULT_SEED_READ_TIMEOUT_MS}. Bounds a
@@ -147,6 +174,15 @@ export interface SeedBootstrapConfig {
147
174
  * returned without applying any seed.
148
175
  */
149
176
  maxConcurrentSeeds?: number;
177
+ /**
178
+ * Time {@link SeedBootstrapService.deliverSeed} waits for the whole exchange —
179
+ * dial, write, ack read — before aborting (ms). Defaults to
180
+ * {@link DEFAULT_SEED_DELIVER_TIMEOUT_MS}. Bounds the SENDER against a seed
181
+ * target that accepts the stream and then never replies; the target is a
182
+ * not-yet-trusted node during onboarding, so this is the more exposed
183
+ * direction than the receiver knobs above.
184
+ */
185
+ seedDeliverTimeoutMs?: number;
150
186
  }
151
187
 
152
188
  /**
@@ -155,8 +191,15 @@ export interface SeedBootstrapConfig {
155
191
  export interface SeedEventCallbacks {
156
192
  /** Called when a seed is received via the protocol */
157
193
  onSeedReceived?: (partyId: string, peerId: string) => void;
158
- /** Called when a seed is successfully applied */
159
- onSeedApplied?: (partyId: string, peersAdded: number) => void;
194
+ /**
195
+ * Called when a seed is successfully applied.
196
+ *
197
+ * `seed` is the applied seed itself: the inbound protocol handler applies it
198
+ * INSIDE the service, so this callback is the only seam through which a
199
+ * `CadreNode` sees a wire-delivered seed's contents — which it needs to
200
+ * retain the owner-flagged peers as cold-start bootstrap dial targets.
201
+ */
202
+ onSeedApplied?: (partyId: string, peersAdded: number, seed: ControlNetworkSeed) => void;
160
203
  /** Called when seed application fails */
161
204
  onSeedError?: (partyId: string, error: string) => void;
162
205
  }
@@ -176,15 +219,17 @@ export class SeedBootstrapService {
176
219
  private readonly trustPolicy: SeedTrustPolicy;
177
220
  private readonly seedReadTimeoutMs: number;
178
221
  private readonly maxConcurrentSeeds: number;
222
+ private readonly seedDeliverTimeoutMs: number;
179
223
  /** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
180
224
  private activeStreams = 0;
181
225
  private eventCallbacks: SeedEventCallbacks = {};
182
226
 
183
227
  constructor(config: SeedBootstrapConfig) {
184
228
  this.config = config;
185
- this.trustPolicy = config.trustPolicy ?? dbAnchoredTrustPolicy();
229
+ this.trustPolicy = config.trustPolicy ?? anchoredTrustPolicy();
186
230
  this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
187
231
  this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
232
+ this.seedDeliverTimeoutMs = config.seedDeliverTimeoutMs ?? DEFAULT_SEED_DELIVER_TIMEOUT_MS;
188
233
 
189
234
  // Derive public key from private key if not provided
190
235
  if (config.ownerPrivateKey && !config.ownerPublicKey) {
@@ -251,7 +296,7 @@ export class SeedBootstrapService {
251
296
 
252
297
  /**
253
298
  * Authorize a new peer to join the cadre.
254
- * Signs the peer ID with the owner key and inserts into CadrePeer table.
299
+ * Signs a membership voucher with the owner key and inserts into CadrePeer table.
255
300
  *
256
301
  * The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
257
302
  * caller-supplied key, the binding is enforced by construction — `PublicKey` is
@@ -284,9 +329,14 @@ export class SeedBootstrapService {
284
329
  * is its own owner (it holds the owner key): the row is owner-signed
285
330
  * (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
286
331
  * immediately without a follow-up self-update.
332
+ *
333
+ * @returns `true` when this call seated the row, `false` when a concurrent writer
334
+ * (e.g. an {@link authorizePeer} of this node's own id) had already seated it —
335
+ * in which case the row in the database is NOT this record and carries whatever
336
+ * `Sig` that writer had, so the caller must self-update to publish its signature.
287
337
  */
288
- async insertSelfPeerRecord(record: PeerAddressRecord): Promise<void> {
289
- await this.insertCadrePeerRow({
338
+ async insertSelfPeerRecord(record: PeerAddressRecord): Promise<boolean> {
339
+ return await this.insertCadrePeerRow({
290
340
  peerId: record.peerId,
291
341
  publicKey: record.publicKey,
292
342
  multiaddr: record.addrs.join(','),
@@ -296,13 +346,20 @@ export class SeedBootstrapService {
296
346
  }
297
347
 
298
348
  /**
299
- * Shared owner-signed `CadrePeer` INSERT. Mints a fresh single-use `StampId`
300
- * and signs the voucher digest `digest(peerId, stampId)` ({@link cadrePeerVoucherDigest})
301
- * with the owner key (satisfying `AuthorizedInsert`), then writes the full record
302
- * row with the vouching (owner, signature) persisted into VouchOwner/VouchSig.
303
- * The owner signature does NOT cover the address columns — those are vouched only
304
- * as far as the owner asserts them, and a peer's own `Sig` (when present) is what
305
- * makes the row resolvable.
349
+ * Shared owner-signed `CadrePeer` INSERT — the row fields and the owner-key
350
+ * precondition; the stamp mint, voucher signature, write lock, existence check and
351
+ * membership notify are all {@link ControlDatabase.insertCadrePeer}'s (see there for
352
+ * the anti-replay and insert-race rationale). No `CadrePeer` write may bypass that
353
+ * method: the write is what admits the peer's traffic.
354
+ *
355
+ * The DB check precedes the owner-key check here, matching the order this method has
356
+ * always surfaced them (the owner-key error used to come out of the signer, after the
357
+ * DB check).
358
+ *
359
+ * @returns `true` when this call performed the INSERT, `false` when the row was
360
+ * already seated by a concurrent writer. The loser needs to know: an authorize seats
361
+ * a row with a null `Sig`, so a self-publish that lost the race must fall through to
362
+ * a self-update or its record never lands.
306
363
  */
307
364
  private async insertCadrePeerRow(row: {
308
365
  peerId: string;
@@ -310,86 +367,78 @@ export class SeedBootstrapService {
310
367
  multiaddr: string;
311
368
  updatedAt: number;
312
369
  sig: string | null;
313
- }): Promise<void> {
370
+ }): Promise<boolean> {
314
371
  if (!this.controlDatabase) {
315
372
  throw new Error('Control database not initialized');
316
373
  }
317
- // Fresh single-use nonce; the voucher signs digest(peerId, stampId) so a captured
318
- // insert can't be replayed (StampId is unique) and can't be repurposed as a delete
319
- // (which signs a distinct 'remove'-scoped digest).
320
- // NOTE: StampId uniqueness only blocks insert-replay while the row (or its StampId)
321
- // is still present — a delete frees the StampId, so a captured authorized insert could
322
- // be replayed to re-add a peer the owner had removed (a revocation bypass). Fully
323
- // subsumed by the connection gater (ticket 6): with no outsider write there is no
324
- // replay at all. If that gate ever proves insufficient, bind inserts to a persistent
325
- // per-peer sequence / tombstone instead of a row-scoped nonce.
326
- const stampId = generateStampId(row.peerId);
327
- const signature = this.signDigest(cadrePeerVoucherDigest(row.peerId, stampId));
328
- const db = this.controlDatabase.getDatabase();
329
- // Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig)
330
- // — identical to the context pair, which the AuthorizedInsert constraint binds — so a
331
- // reader can later re-check the voucher against its node-local trusted-owner anchor.
332
- await db.exec(`
333
- insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
334
- with context OwnerKey = ?, Signature = ?
335
- values (?, ?, ?, ?, ?, ?, ?, ?)
336
- `, [this.ownerPublicKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, this.ownerPublicKey, signature]);
374
+ const ownerKey = this.requireOwnerPublicKey();
375
+ return await this.controlDatabase.insertCadrePeer(
376
+ row, ownerKey, message => this.signMessageBytes(message),
377
+ );
337
378
  }
338
379
 
339
380
  /**
340
381
  * Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
341
382
  *
342
383
  * Counterpart to {@link insertSelfPeerRecord} for the device-token registry: the
343
- * row is owner-signed (satisfying `DeviceToken.AuthorizedInsert`, which vouches
344
- * membership exactly as `CadrePeer.AuthorizedInsert` does) AND carries the peer's
345
- * own self-`Sig` over the token payload. The owner signature covers only the
346
- * PeerId — it does NOT vouch the token contents; the peer's `Sig` (verified at
384
+ * row is owner-signed (satisfying `DeviceToken.AuthorizedInsert` via the
385
+ * 'add'-tagged digest, {@link deviceTokenAddDigest}) AND carries the peer's
386
+ * own self-`Sig` over the token payload. The owner signature covers the WHOLE row
387
+ * (every column, ending in a freshly minted single-use `StampId`) — but covering the
388
+ * token contents is not the same as vouching them: the peer's `Sig` (verified at
347
389
  * resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
348
390
  * resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
349
391
  * when the node is its own owner.
392
+ *
393
+ * The stamp is per-INSERT, so a re-register after a clear mints a fresh one and a
394
+ * fresh signature — unaffected by the cleared row's retired stamp
395
+ * (`DeviceToken.NotRevoked`).
350
396
  */
351
397
  async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
352
- const signature = this.signPeerAuthorization(record.peerId);
398
+ const stampId = generateStampId(record.peerId);
399
+ const signature = this.signDigest(deviceTokenAddDigest({ ...record, stampId }));
353
400
  if (!this.controlDatabase) {
354
401
  throw new Error('Control database not initialized');
355
402
  }
356
- const db = this.controlDatabase.getDatabase();
357
- await db.exec(`
358
- insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig)
403
+ await this.controlDatabase.execWrite(`
404
+ insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig, StampId)
359
405
  with context OwnerKey = ?, Signature = ?
360
- values (?, ?, ?, ?, ?)
361
- `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig]);
406
+ values (?, ?, ?, ?, ?, ?)
407
+ `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig, stampId]);
362
408
  log('Device token inserted (owner-signed): %s', record.peerId);
363
409
  }
364
410
 
365
411
  /**
366
412
  * Owner-signed DELETE of a peer's `DeviceToken` row (logout / token
367
- * invalidation). The `DeviceToken.AuthorizedInsert` constraint gates both insert
368
- * AND delete on an owner signature over `digest(old.PeerId)`, so — like
369
- * {@link removePeer} for `CadrePeer` — clearing a token requires the owner key.
413
+ * invalidation). The `DeviceToken.AuthorizedDelete` constraint validates an owner
414
+ * signature over the 'remove'-tagged digest bound to the STORED row's
415
+ * (PeerId, StampId) — deliberately distinct from the insert digest, so a captured
416
+ * insert approval can never be replayed to clear a token. Like {@link removePeer}
417
+ * for `CadrePeer`, clearing a token requires the owner key.
418
+ *
419
+ * The delete and the `Revocation` tombstone retiring the row's `StampId` commit in
420
+ * ONE transaction — `DeviceToken.RevocationRecorded` refuses a bare delete, and
421
+ * without the tombstone the stamp would free up and the never-expiring insert
422
+ * approval (which the cleared device holds a copy of) would re-seat the token. The
423
+ * tombstone carries its OWN owner signature: retiring a stamp permanently forecloses
424
+ * that row, so it is an owner action in its own right.
425
+ *
426
+ * Both digests, the stamp read, and the transaction come from
427
+ * {@link ControlDatabase.deleteDeviceToken} — one shared implementation across
428
+ * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
429
+ * owner-key precondition; a no-op on an already-absent row is the shared body's
430
+ * behavior, and unlike {@link removePeer} nothing here rides on it.
370
431
  */
371
432
  async deleteDeviceToken(peerId: string): Promise<void> {
372
- const signature = this.signPeerAuthorization(peerId);
433
+ // Fail fast on a keyless service BEFORE the DB read, so a non-owner gets the
434
+ // owner-key error rather than a silent no-op on an absent row.
435
+ const ownerKey = this.requireOwnerPublicKey();
373
436
  if (!this.controlDatabase) {
374
437
  throw new Error('Control database not initialized');
375
438
  }
376
- const db = this.controlDatabase.getDatabase();
377
- await db.exec(`
378
- delete from CadreControl.DeviceToken
379
- with context OwnerKey = ?, Signature = ?
380
- where PeerId = ?
381
- `, [this.ownerPublicKey, signature, peerId]);
382
- log('Device token removed (owner-signed): %s', peerId);
383
- }
384
-
385
- /**
386
- * Sign a peer ID with the owner key for a `CadrePeer` / `DeviceToken`
387
- * insert-or-delete. The signed bytes come from the shared
388
- * {@link peerAuthorizationDigest} helper so the offline `cadre enroll register`
389
- * verifier checks the exact same construction. Throws if no owner key is set.
390
- */
391
- private signPeerAuthorization(peerId: string): string {
392
- return this.signDigest(peerAuthorizationDigest(peerId));
439
+ await this.controlDatabase.deleteDeviceToken(
440
+ peerId, ownerKey, message => this.signMessageBytes(message),
441
+ );
393
442
  }
394
443
 
395
444
  /**
@@ -407,11 +456,28 @@ export class SeedBootstrapService {
407
456
  return this.config.ownerPrivateKey;
408
457
  }
409
458
 
459
+ /**
460
+ * The owner PUBLIC key that rides in every owner-signed write's context, non-null.
461
+ *
462
+ * The field is nullable because a read-only service carries no owner key at all, but the
463
+ * constructor derives the public key whenever `ownerPrivateKey` is set — so gating on
464
+ * {@link requireOwnerPrivateKey} first makes the pair inseparable and the second throw
465
+ * unreachable. Callers that must reject a keyless service BEFORE any DB read use this as
466
+ * their first line and get the owner-key precondition for free.
467
+ */
468
+ private requireOwnerPublicKey(): string {
469
+ this.requireOwnerPrivateKey();
470
+ if (!this.ownerPublicKey) {
471
+ throw new Error('Owner public key required to authorize peers');
472
+ }
473
+ return this.ownerPublicKey;
474
+ }
475
+
410
476
  /**
411
477
  * Sign a base64url digest with the owner key (ed25519). The single place the
412
- * owner private key is applied; callers pass the canonical digest for the specific
413
- * action ({@link cadrePeerVoucherDigest} / {@link cadrePeerRemoveDigest} /
414
- * {@link peerAuthorizationDigest}). Throws if no owner key is set.
478
+ * owner private key is applied; callers pass the canonical domain-tagged digest for
479
+ * the specific action ({@link deviceTokenAddDigest}), or the raw-bytes form via
480
+ * {@link signMessageBytes}. Throws if no owner key is set.
415
481
  */
416
482
  private signDigest(digestB64url: string): string {
417
483
  return sign(
@@ -424,60 +490,83 @@ export class SeedBootstrapService {
424
490
  ) as string;
425
491
  }
426
492
 
493
+ /**
494
+ * Adapt the control database's raw-bytes `signMessage` callback (every guarded delete
495
+ * takes one) to {@link signDigest}'s base64url-string form. Both encodings hash to the
496
+ * same signed bytes (`sign` decodes its base64url input), so a signature minted here
497
+ * satisfies the same schema CHECK as one from the callers that sign the bytes directly — see
498
+ * `control-revocation-replay.spec.ts`'s "raw-bytes and digest-string signers agree".
499
+ */
500
+ private signMessageBytes(message: Uint8Array): string {
501
+ return this.signDigest(uint8ArrayToString(message, 'base64url'));
502
+ }
503
+
427
504
  /**
428
505
  * Remove a peer from the cadre by owner signature.
429
506
  *
430
507
  * The `CadrePeer.AuthorizedDelete` (`check on delete`) constraint validates a
431
- * signature over the DISTINCT 'remove'-scoped digest `digest(old.PeerId, old.StampId,
432
- * 'remove')` ({@link cadrePeerRemoveDigest}) by an owner key — deliberately NOT the
433
- * insert voucher digest, so the row's stored `VouchSig` can never be replayed to delete.
508
+ * signature over the DISTINCT 'remove'-tagged digest
509
+ * `digest('CadreControl.CadrePeer', 'remove', old.PeerId, old.StampId)` by an owner
510
+ * key — deliberately NOT the insert voucher digest, so the row's stored `VouchSig` can
511
+ * never be replayed to delete.
512
+ *
513
+ * The delete and the `Revocation` tombstone retiring the row's `StampId` commit in ONE
514
+ * transaction — `CadrePeer.RevocationRecorded` refuses a bare delete, and without the
515
+ * tombstone the stamp would free up and the original admission approval (which never
516
+ * expires, and which the removed peer holds a copy of) would re-seat the row.
517
+ *
518
+ * The tombstone is separately owner-signed (satisfying `Revocation.Authorized`):
519
+ * retiring a stamp evicts that peer party-wide and permanently forecloses re-admitting
520
+ * the row, so it is an owner action in its own right, not a side effect the delete's
521
+ * signature covers.
522
+ *
523
+ * Both digests, the stamp read, the transaction, and the post-commit membership notify
524
+ * come from {@link ControlDatabase.deleteCadrePeer} — one shared implementation across
525
+ * `CadrePeer` / `DeviceToken` / `Strand` / `ValidationKey`. What stays here is the
526
+ * owner-key precondition and the absent-row gate the notify depends on (below).
434
527
  */
435
528
  async removePeer(peerId: string): Promise<void> {
436
529
  // Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
437
530
  // remove digest, and this precedence (owner key, then control DB) is what the
438
531
  // unit contract asserts.
439
- this.requireOwnerPrivateKey();
532
+ const ownerKey = this.requireOwnerPublicKey();
440
533
  if (!this.controlDatabase) {
441
534
  throw new Error('Control database not initialized');
442
535
  }
443
- // Delete is authorized by a signature over a DISTINCT 'remove'-scoped digest bound to
444
- // the row's CURRENT StampId — so the row's stored voucher (a signature over the
445
- // voucher digest) can never be replayed to authorize a delete.
536
+ // This absent-row gate must stay OUTSIDE the delete, even though the delete repeats it
537
+ // internally: deleteCadrePeer's membership notify fires whenever its body resolves,
538
+ // with no idea whether the body wrote anything, so delegating an absent peer would
539
+ // fire a spurious membership notification.
540
+ // NOTE: deleteCadrePeer re-reads the StampId, so a peer removed by another writer
541
+ // between the two reads no-ops silently yet still notifies. Narrow concurrent-removal
542
+ // window only — the common "already absent" case is caught here.
446
543
  const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
447
544
  if (stampId === null) {
448
545
  log('removePeer: no CadrePeer row for %s (already absent)', peerId);
449
546
  return;
450
547
  }
451
- const signature = this.signDigest(cadrePeerRemoveDigest(peerId, stampId));
452
548
 
453
549
  log('Removing peer: %s', peerId);
454
550
 
455
- const db = this.controlDatabase.getDatabase();
456
- await db.exec(`
457
- delete from CadreControl.CadrePeer
458
- with context OwnerKey = ?, Signature = ?
459
- where PeerId = ?
460
- `, [this.ownerPublicKey, signature, peerId]);
551
+ await this.controlDatabase.deleteCadrePeer(
552
+ peerId, ownerKey, message => this.signMessageBytes(message),
553
+ );
461
554
 
462
- log('Peer %s removed successfully', peerId);
555
+ log('Peer %s removed successfully (stamp retired)', peerId);
463
556
  }
464
557
 
465
558
  /**
466
- * Owner "re-touch" of an existing `CadrePeer` membership row: bump
467
- * `UpdatedAt` under the owner branch of `CadrePeer.AuthorizedUpdate` (a
468
- * signature over `digest(peerId)` — the same construction {@link authorizePeer}
469
- * uses) so the row is re-emitted as a fresh, broadcasting transaction.
559
+ * Owner "re-touch" of an existing `CadrePeer` membership row: bump `UpdatedAt` and
560
+ * re-vouch the row so it is re-emitted as a fresh, broadcasting transaction. This is
561
+ * the write-while-alone re-replication primitive (`control-write-ensure-replicated`):
562
+ * a membership row that committed local-only (its block's cluster ≤1 at insert) is
563
+ * pushed to the cohort once it grows.
564
+ *
565
+ * The stamp read, the voucher signature, the write lock and the membership notify are
566
+ * {@link ControlDatabase.reauthorizeCadrePeer}'s — including the caveat that it rebinds
567
+ * `VouchOwner` to this node's owner key. What stays here is the owner-key precondition.
470
568
  *
471
- * This is the write-while-alone re-replication primitive
472
- * (`control-write-ensure-replicated`): a membership row that committed
473
- * local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
474
- * grows, by re-issuing this monotonic bump. It is an UPDATE (not the original
475
- * INSERT) because the row already exists locally; a re-INSERT would hit the
476
- * `PeerId` PK. Only the freshness stamp changes — `PublicKey` / `Multiaddr` /
477
- * `Sig` are left intact — so it is safe over a row whose peer has not
478
- * self-published (`Sig` null); the caller must skip a row that already carries a
479
- * self-`Sig` (that row is the owning peer's to refresh, and bumping `UpdatedAt`
480
- * without re-signing would invalidate its self-signature).
569
+ * A no-op (no throw, no notify) when the row does not exist.
481
570
  *
482
571
  * @param peerId - the membership row to re-touch.
483
572
  * @param updatedAt - the strictly-increasing freshness stamp to write.
@@ -487,35 +576,48 @@ export class SeedBootstrapService {
487
576
  async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
488
577
  // Fail fast on a keyless service before any DB read (see removePeer): a non-owner
489
578
  // cannot re-sign the voucher, and must not silently no-op on an absent row.
490
- this.requireOwnerPrivateKey();
579
+ const ownerKey = this.requireOwnerPublicKey();
491
580
  if (!this.controlDatabase) {
492
581
  throw new Error('Control database not initialized');
493
582
  }
494
- // The owner branch of AuthorizedUpdate verifies a voucher over digest(PeerId,
495
- // StampId) and re-binds VouchOwner/VouchSig. Sign over the row's CURRENT StampId
496
- // (unchanged by this re-touch) and re-set the voucher columns so the branch passes.
497
- // NOTE: this rebinds VouchOwner to THIS node's owner key. Benign today — the
498
- // write-while-alone drain only re-touches rows this node itself authored. If a future
499
- // path lets one owner re-touch a row a DIFFERENT owner vouched, the voucher
500
- // silently flips to this owner; that only matters once ticket-4's predicate checks
501
- // VouchOwner against a node-local anchor, and only if the two owners differ in
502
- // anchor membership.
503
- const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
504
- if (stampId === null) {
583
+ const retouched = await this.controlDatabase.reauthorizeCadrePeer(
584
+ peerId, updatedAt, ownerKey, message => this.signMessageBytes(message),
585
+ );
586
+ if (!retouched) {
505
587
  log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
506
588
  return;
507
589
  }
508
- const signature = this.signDigest(cadrePeerVoucherDigest(peerId, stampId));
509
- const db = this.controlDatabase.getDatabase();
510
- await db.exec(`
511
- update CadreControl.CadrePeer
512
- with context OwnerKey = ?, Signature = ?
513
- set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
514
- where PeerId = ?
515
- `, [this.ownerPublicKey, signature, updatedAt, this.ownerPublicKey, signature, peerId]);
516
590
  log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
517
591
  }
518
592
 
593
+ /**
594
+ * Owner re-issue of a batch of `Revocation` tombstones: bump each row's
595
+ * `ReissuedAt` so the storage layer re-broadcasts a tombstone that committed
596
+ * while the node was alone. The delete-while-alone counterpart of
597
+ * {@link reauthorizePeer} — a removed row cannot be re-touched (it is gone),
598
+ * but its tombstone can, and every membership read treats a retired stamp as
599
+ * absent.
600
+ *
601
+ * The signatures, the single transaction, and the strictly-monotonic
602
+ * `reissuedAt` contract are {@link ControlDatabase.reissueRevocations}'s. What
603
+ * stays here is the owner-key precondition.
604
+ *
605
+ * @returns how many tombstones were re-issued (`rows.length` on success).
606
+ * @throws if no owner private key is configured or the control database is not
607
+ * initialized.
608
+ */
609
+ async reissueRevocations(rows: readonly RevocationRow[], reissuedAt: number): Promise<number> {
610
+ // Fail fast on a keyless service before any DB work (see removePeer): a
611
+ // non-owner cannot sign the reissue digests.
612
+ const ownerKey = this.requireOwnerPublicKey();
613
+ if (!this.controlDatabase) {
614
+ throw new Error('Control database not initialized');
615
+ }
616
+ return this.controlDatabase.reissueRevocations(
617
+ rows, reissuedAt, ownerKey, message => this.signMessageBytes(message),
618
+ );
619
+ }
620
+
519
621
  /**
520
622
  * Create a seed from the current control network state.
521
623
  * The seed contains peer information and is signed by an owner.
@@ -567,10 +669,15 @@ export class SeedBootstrapService {
567
669
  *
568
670
  * Validates the seed signature, then evaluates a trust anchor for the
569
671
  * `signerKey` that does NOT come from the seed body: the receiver's
570
- * `OwnerKey` table (DB-anchored), optionally augmented by pinned keys or
571
- * TOFU via the configured/overriding `SeedTrustPolicy`. A forged
572
- * self-asserting seed — one that merely lists its own signer as an owner
573
- * peer — no longer passes.
672
+ * node-local {@link SeedBootstrapConfig.trustedOwners} anchor, optionally
673
+ * augmented by pinned keys or TOFU via the configured/overriding
674
+ * `SeedTrustPolicy`. A forged self-asserting seed — one that merely lists its
675
+ * own signer as an owner peer — no longer passes, and neither does one signed
676
+ * by a key a stranger genesis-inserted into the replicated `OwnerKey` table.
677
+ *
678
+ * A key accepted via a pin/TOFU is persisted into the anchor (the policy says
679
+ * so via `SeedTrustDecision.anchorAs`), so the next seed from that owner is
680
+ * anchored without re-supplying the invite.
574
681
  *
575
682
  * @param seed - The seed to apply (already transport-decoded).
576
683
  * @param options.trustPolicy - Per-call policy override (e.g. a
@@ -582,22 +689,29 @@ export class SeedBootstrapService {
582
689
  options?: { trustPolicy?: SeedTrustPolicy }
583
690
  ): Promise<ApplySeedResult> {
584
691
  if (!this.libp2pNode) {
585
- return { success: false, peersAdded: 0, error: 'Service not initialized' };
692
+ return seedRejected('Service not initialized');
586
693
  }
587
694
 
695
+ // NOTE: `seed.partyId` is never checked against `config.partyId`. Nothing
696
+ // downstream reads it — trust is keyed on `signerKey` vs the anchor, and the
697
+ // anchor a stray-party seed could write into belongs to THIS party, which
698
+ // only a caller-supplied pin for that signer can reach. If applying a seed
699
+ // ever branches on its partyId (or the anchor becomes multi-party), reject a
700
+ // mismatch here instead.
588
701
  log('Applying seed for party: %s', seed.partyId);
589
702
 
590
703
  // Validate the seed signature
591
704
  if (!this.validateSeedSignature(seed)) {
592
- return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
705
+ return seedRejected('Invalid seed signature');
593
706
  }
594
707
 
595
- // Evaluate the trust anchor for the signer key. The known-owner set is
596
- // sourced from the receiver's control DB, never from the seed itself; a
597
- // cold-start node with no DB and no override therefore sees an empty set.
598
- const knownOwnerKeys = this.controlDatabase
599
- ? await this.controlDatabase.getOwnerKeys()
600
- : new Set<string>();
708
+ // Evaluate the trust anchor for the signer key. The known-owner set comes
709
+ // from the receiver's NODE-LOCAL anchor — never from the seed itself, and
710
+ // never from the replicated OwnerKey table (a stranger can genesis-insert
711
+ // its own key there and let it replicate into every peer's copy). A node
712
+ // whose anchor was never seeded, with no policy override, sees an empty set
713
+ // and rejects.
714
+ const knownOwnerKeys = this.config.trustedOwners?.all() ?? new Set<string>();
601
715
  const policy = options?.trustPolicy ?? this.trustPolicy;
602
716
  const decision = await policy.evaluate({
603
717
  partyId: seed.partyId,
@@ -605,12 +719,9 @@ export class SeedBootstrapService {
605
719
  knownOwnerKeys,
606
720
  });
607
721
  if (!decision.trusted) {
608
- return {
609
- success: false,
610
- peersAdded: 0,
611
- error: decision.reason ?? 'Signer key not trusted by trust policy',
612
- };
722
+ return seedRejected(decision.reason ?? 'Signer key not trusted by trust policy');
613
723
  }
724
+ await this.anchorAcceptedSigner(seed.signerKey, decision);
614
725
 
615
726
  let peersAdded = 0;
616
727
 
@@ -634,23 +745,71 @@ export class SeedBootstrapService {
634
745
  }
635
746
  }
636
747
 
637
- // Dial owner peers to establish connections
748
+ // Dial owner peers to establish connections. Best-effort and COUNTED: an
749
+ // owner that is momentarily down leaves this node seeded but unconnected,
750
+ // which the caller can only see if the outcome is reported (see
751
+ // `ApplySeedResult.ownerDialsFailed`). Recovery is not this loop's job —
752
+ // `CadreNode.dialColdStartBootstrap` retries these same addresses on every
753
+ // control-cohort reconcile pass until the control database has siblings.
754
+ // `createSeed` projects EVERY CadrePeer row, so an owner applying a seed
755
+ // minted after it joined finds ITSELF in the owner list. Dialing self always
756
+ // throws, which would report a healthy owner as "seeded but stranded".
757
+ // Optional-chained: partial libp2p handles (unit-test doubles) omit `peerId`,
758
+ // and an undefined self simply matches nothing.
759
+ const selfPeerId = this.libp2pNode.peerId?.toString();
760
+ let ownerDialsAttempted = 0;
761
+ let ownerDialsFailed = 0;
638
762
  for (const peer of seed.peers.filter(p => p.isOwner)) {
763
+ if (peer.multiaddrs.length === 0 || peer.peerId === selfPeerId) {
764
+ continue;
765
+ }
766
+ ownerDialsAttempted++;
639
767
  try {
640
- if (peer.multiaddrs.length > 0) {
641
- const addr = multiaddr(peer.multiaddrs[0]);
768
+ const addr = multiaddr(peer.multiaddrs[0]);
642
769
 
643
- log('Dialing owner peer: %s', peer.peerId);
644
- await this.libp2pNode.dial(addr);
645
- }
770
+ log('Dialing owner peer: %s', peer.peerId);
771
+ await this.libp2pNode.dial(addr);
646
772
  } catch (error) {
773
+ ownerDialsFailed++;
647
774
  log('Failed to dial peer %s: %o', peer.peerId, error);
648
775
  // Continue - not all peers need to be reachable
649
776
  }
650
777
  }
651
778
 
652
- log('Applied seed: %d peers added', peersAdded);
653
- return { success: true, peersAdded };
779
+ log('Applied seed: %d peers added, %d/%d owner dial(s) failed',
780
+ peersAdded, ownerDialsFailed, ownerDialsAttempted);
781
+ return { success: true, peersAdded, ownerDialsAttempted, ownerDialsFailed };
782
+ }
783
+
784
+ /**
785
+ * Persist a signer that a pin/TOFU accepted into the node-local anchor, so a
786
+ * later seed from the same owner is anchored without re-supplying the invite
787
+ * or re-prompting. Only the policy decides this happens (`anchorAs` is unset
788
+ * when the key was already anchored, so a plain re-apply writes nothing) and
789
+ * `trust()` is idempotent, keeping the original provenance for a known key.
790
+ *
791
+ * Failure to PERSIST does not fail the seed: `trust()` reflects the key in the
792
+ * in-memory anchor synchronously, so this seed and the rest of the session are
793
+ * unaffected — only durability across a restart is lost, and that is logged.
794
+ *
795
+ * NOTE: anchoring a key can flip `CadrePeer` rows ALREADY present from
796
+ * unauthorized to authorized, which the write-driven membership-gate refresh
797
+ * (`ControlDatabase.mutateCadrePeer`) cannot see — no row was written. Every
798
+ * anchor mutation today rides seed application, and both seed paths refresh the
799
+ * gate explicitly afterwards (`CadreNode.applySeed`, `onSeedApplied`). If some
800
+ * future path anchors an owner OUTSIDE seed application, it owes the same
801
+ * `CadreNode.refreshMembershipGate()` — or the anchor needs its own hub.
802
+ */
803
+ private async anchorAcceptedSigner(signerKey: string, decision: SeedTrustDecision): Promise<void> {
804
+ if (!decision.anchorAs || !this.config.trustedOwners) {
805
+ return;
806
+ }
807
+ try {
808
+ await this.config.trustedOwners.trust(signerKey, decision.anchorAs);
809
+ log('Anchored seed signer %s as %s', signerKey, decision.anchorAs);
810
+ } catch (error) {
811
+ log('Failed to persist accepted seed signer into the trusted-owner anchor: %o', error);
812
+ }
654
813
  }
655
814
 
656
815
  /**
@@ -672,61 +831,86 @@ export class SeedBootstrapService {
672
831
 
673
832
  /**
674
833
  * Deliver a seed directly to a peer via the /sereus/seed/1.0.0 protocol.
834
+ *
835
+ * Sender hardening: the whole exchange — dial, write, ack read — is bounded by
836
+ * {@link seedDeliverTimeoutMs}, and the ack is capped at {@link MAX_SEED_SIZE}.
837
+ * The target is a NOT-YET-TRUSTED node the instigator chose to dial during
838
+ * onboarding, so an unbounded read here is strictly more exposed than the
839
+ * membership-gated receiver paths: without the bound a target that accepts the
840
+ * stream and never replies parks this call forever, and one that streams
841
+ * arbitrary bytes as a fake ack exhausts memory.
675
842
  */
676
843
  async deliverSeed(targetMultiaddr: string, seed: ControlNetworkSeed): Promise<SeedAckMessage> {
677
844
  if (!this.libp2pNode) {
678
845
  throw new Error('Service not initialized');
679
846
  }
680
-
847
+ // Capture the node so the closure below needs no non-null assertion.
848
+ const node = this.libp2pNode;
681
849
  const addr = multiaddr(targetMultiaddr);
682
850
 
683
851
  log('Delivering seed to: %s', targetMultiaddr);
684
852
 
685
- // Dial the target and open a stream
686
- const rawStream = await this.libp2pNode.dialProtocol(addr, SEED_PROTOCOL);
687
- const stream = rawStream as unknown as ControlStream;
688
-
689
- try {
690
- // Send the seed message as one length-prefixed frame (shared writer).
691
- const message: SeedMessage = {
692
- partyId: seed.partyId,
693
- peers: seed.peers,
694
- signature: seed.signature,
695
- signerKey: seed.signerKey,
696
- };
697
-
698
- writeFrame(stream, message);
699
-
700
- // In libp2p v3.x, close() closes the write end only (signals EOF),
701
- // while the read end remains open for receiving the ack.
702
- await stream.close();
853
+ return await withDeadline(
854
+ this.seedDeliverTimeoutMs,
855
+ `Seed delivery to ${targetMultiaddr}`,
856
+ (signal) => this.sendSeed(node, addr, seed, signal),
857
+ );
858
+ }
703
859
 
704
- // Read the acknowledgment (stream is AsyncIterable in libp2p 3.x)
705
- const chunks: Uint8Array[] = [];
706
- for await (const chunk of stream) {
707
- const bytes = chunk instanceof Uint8Array ? chunk : (chunk as { subarray(): Uint8Array }).subarray();
708
- chunks.push(bytes);
709
- }
860
+ /**
861
+ * Open one stream to the target, send the seed frame, half-close, and read the ack.
862
+ *
863
+ * `signal` is the deadline from {@link deliverSeed}: it goes to `dialProtocol` so
864
+ * a timeout during connect aborts the dial, and into {@link exchangeFrame} so a
865
+ * timeout after the stream is open resets it — releasing the otherwise unbounded
866
+ * ack-read.
867
+ *
868
+ * Deliberately NOT `runOnLimitedConnection`: a wake sets it because a wake is a
869
+ * tiny frame over a relay, whereas a seed is up to 1MB and this delivery path
870
+ * does not dial relay addresses today. Changing that is a separate decision.
871
+ */
872
+ private async sendSeed(
873
+ node: Libp2p,
874
+ addr: Multiaddr,
875
+ seed: ControlNetworkSeed,
876
+ signal: AbortSignal,
877
+ ): Promise<SeedAckMessage> {
878
+ const rawStream = await node.dialProtocol(addr, SEED_PROTOCOL, { signal });
710
879
 
711
- const responseData = new Uint8Array(chunks.reduce((sum, c) => sum + c.length, 0));
712
- let offset = 0;
713
- for (const chunk of chunks) {
714
- responseData.set(chunk, offset);
715
- offset += chunk.length;
716
- }
880
+ const message: SeedMessage = {
881
+ partyId: seed.partyId,
882
+ peers: seed.peers,
883
+ signature: seed.signature,
884
+ signerKey: seed.signerKey,
885
+ };
717
886
 
718
- // Parse length-prefixed response
719
- const responseBody = decodeLengthPrefixedFrame(responseData);
720
- const responseJson = new TextDecoder().decode(responseBody);
721
- const ack = JSON.parse(responseJson) as SeedAckMessage;
887
+ const ack = await exchangeFrame(
888
+ rawStream as unknown as ControlStream,
889
+ signal,
890
+ message,
891
+ (stream) => this.readSeedAck(stream),
892
+ 'Seed delivery aborted by timeout',
893
+ );
722
894
 
723
- log('Seed delivery response: accepted=%s', ack.accepted);
724
- return ack;
895
+ log('Seed delivery response: accepted=%s', ack.accepted);
896
+ return ack;
897
+ }
725
898
 
726
- } catch (err) {
727
- stream.abort(err instanceof Error ? err : new Error(String(err)));
728
- throw err;
729
- }
899
+ /**
900
+ * Read the ack frame a delivery target writes back, bounded by
901
+ * {@link seedDeliverTimeoutMs} and capped at {@link MAX_SEED_SIZE} — an
902
+ * untrusted target must not be able to stream unlimited bytes as a fake ack.
903
+ * Decoding runs inside {@link exchangeFrame}'s `try`, so a malformed or
904
+ * non-JSON ack resets the stream rather than leaking it.
905
+ */
906
+ private async readSeedAck(stream: ControlStream): Promise<SeedAckMessage> {
907
+ const data = await readStreamToEnd(stream, {
908
+ maxBytes: MAX_SEED_SIZE,
909
+ timeoutMs: this.seedDeliverTimeoutMs,
910
+ label: 'Seed ack',
911
+ });
912
+ const body = decodeLengthPrefixedFrame(data, MAX_SEED_SIZE);
913
+ return JSON.parse(new TextDecoder().decode(body)) as SeedAckMessage;
730
914
  }
731
915
 
732
916
  /**
@@ -781,6 +965,14 @@ export class SeedBootstrapService {
781
965
  * peer is an owner iff that derived key is in the `OwnerKey` set.
782
966
  * This makes any owner node markable — not just the local one — and ties
783
967
  * `isOwner` to the control table rather than to `peerId === self`.
968
+ *
969
+ * NOTE: this is the one owner lookup deliberately left on the REPLICATED
970
+ * table rather than the node-local anchor. `SeedPeer.isOwner` is a dial hint
971
+ * — the receiver dials owner-flagged peers first — not a trust decision, and
972
+ * the receiver re-derives real trust from its own anchor. So a polluted table
973
+ * costs at most a wasted dial, while anchoring here would silently drop
974
+ * legitimate co-owners this node never pinned. If `isOwner` ever gates
975
+ * anything the receiver TRUSTS, move it to the anchor.
784
976
  */
785
977
  private async queryPeers(): Promise<SeedPeer[]> {
786
978
  if (!this.controlDatabase) {
@@ -885,7 +1077,7 @@ export class SeedBootstrapService {
885
1077
 
886
1078
  // Emit appropriate event based on result
887
1079
  if (result.success) {
888
- this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
1080
+ this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded, seed);
889
1081
  } else {
890
1082
  this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
891
1083
  }
@@ -997,11 +1189,19 @@ export class SeedBootstrapService {
997
1189
  ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
998
1190
  }
999
1191
 
1000
- // Carry the cadre's owner keys out-of-band so a cold-start invitee can
1001
- // pin the trusted owner set before applying any seed.
1002
- const ownerKeys = this.controlDatabase
1003
- ? Array.from(await this.controlDatabase.getOwnerKeys())
1004
- : [];
1192
+ // Carry the cadre's owner keys out-of-band so a cold-start invitee can pin
1193
+ // the trusted owner set before applying any seed.
1194
+ //
1195
+ // Sourced ONLY from this node's own anchor, never from the replicated
1196
+ // OwnerKey table: the invitee anchors whatever arrives here
1197
+ // (CadreNode.trustOwnerKeys with source 'invite'), so handing over the
1198
+ // pollutable table would let a stranger's genesis-inserted key ride an
1199
+ // otherwise-legitimate invite straight into the new node's anchor —
1200
+ // poisoning the very store this whole trust chain rests on. No anchor wired
1201
+ // (a directly-constructed service) means no pins to hand out: an invite
1202
+ // without `ownerKeys` costs the invitee an extra out-of-band step, whereas a
1203
+ // table-sourced one silently hands it an unanchored key.
1204
+ const ownerKeys = Array.from(this.config.trustedOwners?.all() ?? []);
1005
1205
 
1006
1206
  const now = Date.now();
1007
1207
  const invite: CadreInvite = {