@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
@@ -1,20 +1,10 @@
1
1
  import debug from 'debug';
2
2
  import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
3
3
  import { digest, sign, verify, getPublicKey } from '@optimystic/quereus-plugin-crypto';
4
- import type { Libp2p, PeerId, Connection } from '@libp2p/interface';
4
+ import type { Libp2p, Connection } from '@libp2p/interface';
5
5
  import { multiaddr } from '@multiformats/multiaddr';
6
- import type { Multiaddr } from '@multiformats/multiaddr';
7
6
  import { peerIdFromString } from '@libp2p/peer-id';
8
-
9
- /**
10
- * Minimal libp2p stream surface compatible with libp2p 3.x.
11
- * In libp2p 3.x, streams are AsyncIterable for reading and use send() for writing.
12
- */
13
- interface LibP2PStream extends AsyncIterable<Uint8Array> {
14
- send(data: Uint8Array): boolean;
15
- close(): Promise<void>;
16
- abort(err: Error): void;
17
- }
7
+ import { type ControlStream, writeFrame, readStreamToEnd } from './control-stream.js';
18
8
  import type {
19
9
  ControlNetworkSeed,
20
10
  SeedPeer,
@@ -22,14 +12,22 @@ import type {
22
12
  SeedAckMessage,
23
13
  AuthorizePeerOptions,
24
14
  ApplySeedResult,
25
- SignedTransaction,
26
15
  AddDroneOptions,
27
16
  AddPhoneOptions,
28
17
  DroneInitResult,
29
18
  InviteResult,
30
- CadreInvite
19
+ CadreInvite,
20
+ PeerAddressRecord,
21
+ DeviceTokenRecord
31
22
  } from './types.js';
32
23
  import type { ControlDatabase } from './control-database.js';
24
+ import { generateStampId } from './control-database.js';
25
+ import { canonicalJson } from './canonical-json.js';
26
+ import { peerAuthorizationDigest, cadrePeerVoucherDigest, cadrePeerRemoveDigest } from './peer-authorization.js';
27
+ import {
28
+ type SeedTrustPolicy,
29
+ dbAnchoredTrustPolicy,
30
+ } from './seed-trust-policy.js';
33
31
 
34
32
  const log = debug('sereus:cadre:seed-bootstrap');
35
33
 
@@ -39,16 +37,85 @@ export const SEED_PROTOCOL = '/sereus/seed/1.0.0';
39
37
  /** Maximum seed message size (1MB) */
40
38
  const MAX_SEED_SIZE = 1024 * 1024;
41
39
 
40
+ /** Default time the receiver waits for an inbound seed frame before aborting (ms). */
41
+ const DEFAULT_SEED_READ_TIMEOUT_MS = 10_000;
42
+
43
+ /** Default cap on concurrent inbound seed streams a single peer can pin open. */
44
+ const DEFAULT_MAX_CONCURRENT_SEEDS = 100;
45
+
46
+ /**
47
+ * Decode a 4-byte big-endian length-prefixed frame; returns the body bytes.
48
+ *
49
+ * Guards every parse site against malformed input: a buffer too short to hold
50
+ * the prefix, a declared length exceeding `maxLength`, and a declared length
51
+ * exceeding the bytes actually present. Returns a view (`subarray`, no copy) —
52
+ * the body is handed straight to `TextDecoder`.
53
+ */
54
+ export function decodeLengthPrefixedFrame(data: Uint8Array, maxLength = MAX_SEED_SIZE): Uint8Array {
55
+ if (data.length < 4) {
56
+ throw new Error(`Seed frame too short: ${data.length} bytes, need ≥4 for length prefix`);
57
+ }
58
+ // Pass the full (buffer, byteOffset, byteLength) triple so the read is correct
59
+ // even for a non-zero-offset view, not just the fresh zero-offset arrays
60
+ // current callers pass.
61
+ const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(0, false);
62
+ const available = data.length - 4;
63
+ if (length > maxLength) {
64
+ throw new Error(`Seed frame declares length ${length} exceeding max ${maxLength}`);
65
+ }
66
+ if (length > available) {
67
+ throw new Error(`Seed frame declares length ${length} but only ${available} body bytes present`);
68
+ }
69
+ return data.subarray(4, 4 + length);
70
+ }
71
+
72
+ /**
73
+ * Derive the base64url ed25519 public key embedded in an Ed25519 libp2p PeerId.
74
+ *
75
+ * An Ed25519 PeerId is an identity multihash of the public key, so
76
+ * `peerIdFromString(id).publicKey.raw` is the 32-byte ed25519 key whose
77
+ * base64url form matches the `OwnerKey.Key` representation (and
78
+ * `ed25519KeyPairFromLibp2p().publicKeyB64`). Returns null for a non-Ed25519
79
+ * id, a missing embedded key, or any parse failure — callers treat null as
80
+ * "not an owner" rather than throwing.
81
+ */
82
+ export function ed25519PublicKeyB64FromPeerId(peerId: string): string | null {
83
+ try {
84
+ const parsed = peerIdFromString(peerId);
85
+ if (parsed.type !== 'Ed25519' || !parsed.publicKey) {
86
+ return null;
87
+ }
88
+ return uint8ArrayToString(parsed.publicKey.raw, 'base64url');
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Canonical byte representation of the authenticated seed fields.
96
+ *
97
+ * Routes both the creator (`createSeed`) and the verifier
98
+ * (`validateSeedSignature`) through one builder so the signed bytes are
99
+ * identical regardless of key insertion order. `canonicalJson` sorts keys and
100
+ * drops `undefined`, so the signed payload is exactly `{ partyId, peers }` —
101
+ * the fields the producer actually emits.
102
+ */
103
+ export function canonicalSeedPayload(
104
+ seed: Pick<ControlNetworkSeed, 'partyId' | 'peers'>
105
+ ): string {
106
+ return canonicalJson({ partyId: seed.partyId, peers: seed.peers });
107
+ }
108
+
42
109
  /**
43
110
  * Configuration for the SeedBootstrapService
44
111
  */
45
112
  export interface SeedBootstrapConfig {
46
113
  /** Party ID for this cadre */
47
114
  partyId: string;
48
- /** Authority private key for signing seeds and peer authorizations (base64url) */
49
- authorityPrivateKey?: string;
50
- /** Authority public key (base64url) - derived from private key if not provided */
51
- authorityPublicKey?: string;
115
+ /** Owner private key for signing seeds and peer authorizations (base64url) */
116
+ ownerPrivateKey?: string;
117
+ /** Owner public key (base64url) - derived from private key if not provided */
118
+ ownerPublicKey?: string;
52
119
  /**
53
120
  * Optional async resolver returning the multiaddrs to embed in invites.
54
121
  * When unset, `libp2pNode.getMultiaddrs()` is used. Hosts behind NAT supply
@@ -56,6 +123,30 @@ export interface SeedBootstrapConfig {
56
123
  * DDNS hostname and externally-mapped port.
57
124
  */
58
125
  inviteAddressResolver?: () => Promise<string[]>;
126
+ /**
127
+ * 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
131
+ * caller can pass a per-seed override to `applySeed` instead.
132
+ *
133
+ * A `CadreNode` forwards its node-wide `CadreNodeConfig.seedTrustPolicy` here
134
+ * — that is the only seam the inbound libp2p seed-protocol handler can use,
135
+ * since a network-delivered seed has no per-call override.
136
+ */
137
+ trustPolicy?: SeedTrustPolicy;
138
+ /**
139
+ * Time the inbound seed handler waits for the seed frame before aborting the
140
+ * read (ms). Defaults to {@link DEFAULT_SEED_READ_TIMEOUT_MS}. Bounds a
141
+ * buggy/compromised own-cadre node that opens a stream and never half-closes.
142
+ */
143
+ seedReadTimeoutMs?: number;
144
+ /**
145
+ * Cap on concurrent inbound seed streams (defaults to
146
+ * {@link DEFAULT_MAX_CONCURRENT_SEEDS}). Over the cap, a non-accepting ack is
147
+ * returned without applying any seed.
148
+ */
149
+ maxConcurrentSeeds?: number;
59
150
  }
60
151
 
61
152
  /**
@@ -75,28 +166,36 @@ export interface SeedEventCallbacks {
75
166
  *
76
167
  * Seeds solve the cold-start problem: new nodes need control data to validate
77
168
  * connections, but can't get data without connecting first. Seeds pre-populate
78
- * the new node's cache with peer information and optionally transactions.
169
+ * the new node's cache with peer information.
79
170
  */
80
171
  export class SeedBootstrapService {
81
172
  private readonly config: SeedBootstrapConfig;
82
173
  private libp2pNode: Libp2p | null = null;
83
174
  private controlDatabase: ControlDatabase | null = null;
84
- private readonly authorityPublicKey: string | null;
175
+ private readonly ownerPublicKey: string | null;
176
+ private readonly trustPolicy: SeedTrustPolicy;
177
+ private readonly seedReadTimeoutMs: number;
178
+ private readonly maxConcurrentSeeds: number;
179
+ /** In-flight inbound seed streams, used to enforce {@link maxConcurrentSeeds}. */
180
+ private activeStreams = 0;
85
181
  private eventCallbacks: SeedEventCallbacks = {};
86
182
 
87
183
  constructor(config: SeedBootstrapConfig) {
88
184
  this.config = config;
185
+ this.trustPolicy = config.trustPolicy ?? dbAnchoredTrustPolicy();
186
+ this.seedReadTimeoutMs = config.seedReadTimeoutMs ?? DEFAULT_SEED_READ_TIMEOUT_MS;
187
+ this.maxConcurrentSeeds = config.maxConcurrentSeeds ?? DEFAULT_MAX_CONCURRENT_SEEDS;
89
188
 
90
189
  // Derive public key from private key if not provided
91
- if (config.authorityPrivateKey && !config.authorityPublicKey) {
92
- this.authorityPublicKey = getPublicKey(
93
- config.authorityPrivateKey,
190
+ if (config.ownerPrivateKey && !config.ownerPublicKey) {
191
+ this.ownerPublicKey = getPublicKey(
192
+ config.ownerPrivateKey,
94
193
  'ed25519',
95
194
  'base64url',
96
195
  'base64url'
97
196
  ) as string;
98
197
  } else {
99
- this.authorityPublicKey = config.authorityPublicKey ?? null;
198
+ this.ownerPublicKey = config.ownerPublicKey ?? null;
100
199
  }
101
200
 
102
201
  log('SeedBootstrapService created for party: %s', config.partyId);
@@ -111,105 +210,319 @@ export class SeedBootstrapService {
111
210
  }
112
211
 
113
212
  /**
114
- * Initialize the service with libp2p node and control database
213
+ * Whether this service holds an owner private key, i.e. can produce the
214
+ * owner signatures that gate `CadrePeer` / `DeviceToken` inserts, deletes,
215
+ * and re-authorizations. A seed-listener-only service (`enableSeedListener`,
216
+ * no owner key) returns false: it can receive/apply seeds but cannot author
217
+ * or re-issue owner writes. Used by the write-while-alone re-replication
218
+ * drain to skip owner work on a non-owner node.
219
+ */
220
+ canAuthorize(): boolean {
221
+ return !!this.config.ownerPrivateKey;
222
+ }
223
+
224
+ /**
225
+ * Initialize the service with libp2p node and control database.
226
+ *
227
+ * `registerHandler` (default true) gates registration of the shared inbound
228
+ * `/sereus/seed/1.0.0` handler on `libp2pNode`. Persistent services
229
+ * (`initializeSeedBootstrap`, `enableSeedListener`) own that handler and leave
230
+ * it on. The throwaway temp services CadreNode builds in `applySeed` /
231
+ * `dialInvite` pass `false`: they only need the stored `libp2pNode` /
232
+ * `controlDatabase` for dialing and known-key lookup, and must NOT bind a
233
+ * discarded closure to the shared node (a handler leak, and a second
234
+ * `handle()` of the same protocol throws `DuplicateProtocolHandlerError`).
115
235
  */
116
- initialize(libp2pNode: Libp2p, controlDatabase: ControlDatabase): void {
236
+ initialize(
237
+ libp2pNode: Libp2p,
238
+ controlDatabase: ControlDatabase,
239
+ options?: { registerHandler?: boolean }
240
+ ): void {
117
241
  this.libp2pNode = libp2pNode;
118
242
  this.controlDatabase = controlDatabase;
119
-
120
- // Register the seed protocol handler
121
- this.registerProtocolHandler();
122
-
243
+
244
+ // Register the seed protocol handler unless the caller opted out (temp services).
245
+ if (options?.registerHandler ?? true) {
246
+ this.registerProtocolHandler();
247
+ }
248
+
123
249
  log('SeedBootstrapService initialized');
124
250
  }
125
251
 
126
252
  /**
127
253
  * Authorize a new peer to join the cadre.
128
- * Signs the peer ID with the authority key and inserts into CadrePeer table.
254
+ * Signs the peer ID with the owner key and inserts into CadrePeer table.
255
+ *
256
+ * The owner vouches the `PublicKey <-> PeerId` binding: rather than trust a
257
+ * caller-supplied key, the binding is enforced by construction — `PublicKey` is
258
+ * DERIVED from the (Ed25519) `peerId`. A non-Ed25519 peer id yields a null
259
+ * `PublicKey`, and such a row can never be self-updated (it has no key to
260
+ * verify against), which is correct. The row is inserted with a fresh
261
+ * `UpdatedAt` but no self-signature (`Sig` null) — the owner cannot produce
262
+ * the peer's self-signature, so the peer must self-publish (see
263
+ * {@link CadreNode.registerSelf}) before the row resolves.
129
264
  */
130
265
  async authorizePeer(options: AuthorizePeerOptions): Promise<void> {
131
266
  const { peerId, multiaddrs } = options;
132
-
133
- if (!this.config.authorityPrivateKey) {
134
- throw new Error('Authority private key required to authorize peers');
135
- }
136
-
267
+ log('Authorizing peer: %s', peerId);
268
+ // CadrePeer.Multiaddr stores a comma-joined list; use '' when no addrs provided.
269
+ const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
270
+ await this.insertCadrePeerRow({
271
+ peerId,
272
+ publicKey: ed25519PublicKeyB64FromPeerId(peerId),
273
+ multiaddr: multiaddrStr,
274
+ updatedAt: Date.now(),
275
+ sig: null,
276
+ });
277
+ log('Peer %s authorized successfully', peerId);
278
+ }
279
+
280
+ /**
281
+ * Owner-signed INSERT of this node's OWN self-signed address record.
282
+ *
283
+ * Used by {@link CadreNode.registerSelf} when the node is not yet a member and
284
+ * is its own owner (it holds the owner key): the row is owner-signed
285
+ * (satisfying `AuthorizedInsert`) AND carries a valid self-`Sig`, so it resolves
286
+ * immediately without a follow-up self-update.
287
+ */
288
+ async insertSelfPeerRecord(record: PeerAddressRecord): Promise<void> {
289
+ await this.insertCadrePeerRow({
290
+ peerId: record.peerId,
291
+ publicKey: record.publicKey,
292
+ multiaddr: record.addrs.join(','),
293
+ updatedAt: record.updatedAt,
294
+ sig: record.sig,
295
+ });
296
+ }
297
+
298
+ /**
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.
306
+ */
307
+ private async insertCadrePeerRow(row: {
308
+ peerId: string;
309
+ publicKey: string | null;
310
+ multiaddr: string;
311
+ updatedAt: number;
312
+ sig: string | null;
313
+ }): Promise<void> {
137
314
  if (!this.controlDatabase) {
138
315
  throw new Error('Control database not initialized');
139
316
  }
140
-
141
- log('Authorizing peer: %s', peerId);
142
-
143
- // Sign the peer ID with the authority key
144
- const peerIdDigest = digest(peerId, 'sha256', 'utf8', 'base64url') as string;
145
- const signature = sign(
146
- peerIdDigest,
147
- this.config.authorityPrivateKey,
148
- 'ed25519',
149
- 'base64url',
150
- 'base64url',
151
- 'base64url'
152
- ) as string;
153
-
154
- // Insert into CadrePeer table with authority context
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));
155
328
  const db = this.controlDatabase.getDatabase();
156
- // CadrePeer.Multiaddr is NOT NULL; use empty string when no addrs provided
157
- const multiaddrStr = multiaddrs?.length ? multiaddrs.join(',') : '';
158
-
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.
159
332
  await db.exec(`
160
- insert into CadreControl.CadrePeer (PeerId, Multiaddr)
161
- with context AuthorityKey = ?, Signature = ?
162
- values (?, ?)
163
- `, [this.authorityPublicKey, signature, peerId, multiaddrStr]);
164
-
165
- log('Peer %s authorized successfully', peerId);
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]);
166
337
  }
167
338
 
168
339
  /**
169
- * Remove a peer from the cadre by authority signature.
340
+ * Owner-signed INSERT of a peer's OWN self-signed `DeviceToken` row.
170
341
  *
171
- * The constraint over CadrePeer's `check on insert, delete` validates a
172
- * signature over `digest(old.PeerId, 'sha256', 'utf8')` by an authority
173
- * key. We use the same digest pattern as authorizePeer.
342
+ * 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
347
+ * resolve time against the bound `CadrePeer.PublicKey`) is what makes the row
348
+ * resolvable. Used by {@link CadreNode.registerDeviceToken} for the first publish
349
+ * when the node is its own owner.
174
350
  */
175
- async removePeer(peerId: string): Promise<void> {
176
- if (!this.config.authorityPrivateKey) {
177
- throw new Error('Authority private key required to remove peers');
351
+ async insertSelfDeviceToken(record: DeviceTokenRecord): Promise<void> {
352
+ const signature = this.signPeerAuthorization(record.peerId);
353
+ if (!this.controlDatabase) {
354
+ throw new Error('Control database not initialized');
178
355
  }
356
+ const db = this.controlDatabase.getDatabase();
357
+ await db.exec(`
358
+ insert into CadreControl.DeviceToken (PeerId, Platform, Token, UpdatedAt, Sig)
359
+ with context OwnerKey = ?, Signature = ?
360
+ values (?, ?, ?, ?, ?)
361
+ `, [this.ownerPublicKey, signature, record.peerId, record.platform, record.token, record.updatedAt, record.sig]);
362
+ log('Device token inserted (owner-signed): %s', record.peerId);
363
+ }
179
364
 
365
+ /**
366
+ * 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.
370
+ */
371
+ async deleteDeviceToken(peerId: string): Promise<void> {
372
+ const signature = this.signPeerAuthorization(peerId);
180
373
  if (!this.controlDatabase) {
181
374
  throw new Error('Control database not initialized');
182
375
  }
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
+ }
183
384
 
184
- log('Removing peer: %s', peerId);
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));
393
+ }
185
394
 
186
- const peerIdDigest = digest(peerId, 'sha256', 'utf8', 'base64url') as string;
187
- const signature = sign(
188
- peerIdDigest,
189
- this.config.authorityPrivateKey,
395
+ /**
396
+ * Return the configured owner private key, or throw if none is set. The single
397
+ * precondition gate for every owner-signed write. {@link removePeer} /
398
+ * {@link reauthorizePeer} read the row's `StampId` from the DB BEFORE they sign, so
399
+ * they call this up front — otherwise a keyless service would either surface the
400
+ * wrong "Control database not initialized" error or, worse, silently no-op when the
401
+ * target row is absent (the early `stampId === null` return) instead of rejecting.
402
+ */
403
+ private requireOwnerPrivateKey(): string {
404
+ if (!this.config.ownerPrivateKey) {
405
+ throw new Error('Owner private key required to authorize peers');
406
+ }
407
+ return this.config.ownerPrivateKey;
408
+ }
409
+
410
+ /**
411
+ * 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.
415
+ */
416
+ private signDigest(digestB64url: string): string {
417
+ return sign(
418
+ digestB64url,
419
+ this.requireOwnerPrivateKey(),
190
420
  'ed25519',
191
421
  'base64url',
192
422
  'base64url',
193
423
  'base64url'
194
424
  ) as string;
425
+ }
426
+
427
+ /**
428
+ * Remove a peer from the cadre by owner signature.
429
+ *
430
+ * 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.
434
+ */
435
+ async removePeer(peerId: string): Promise<void> {
436
+ // Fail fast on a keyless service BEFORE any DB read: a non-owner cannot sign the
437
+ // remove digest, and this precedence (owner key, then control DB) is what the
438
+ // unit contract asserts.
439
+ this.requireOwnerPrivateKey();
440
+ if (!this.controlDatabase) {
441
+ throw new Error('Control database not initialized');
442
+ }
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.
446
+ const stampId = await this.controlDatabase.queryCadrePeerStampId(peerId);
447
+ if (stampId === null) {
448
+ log('removePeer: no CadrePeer row for %s (already absent)', peerId);
449
+ return;
450
+ }
451
+ const signature = this.signDigest(cadrePeerRemoveDigest(peerId, stampId));
452
+
453
+ log('Removing peer: %s', peerId);
195
454
 
196
455
  const db = this.controlDatabase.getDatabase();
197
456
  await db.exec(`
198
457
  delete from CadreControl.CadrePeer
199
- with context AuthorityKey = ?, Signature = ?
458
+ with context OwnerKey = ?, Signature = ?
200
459
  where PeerId = ?
201
- `, [this.authorityPublicKey, signature, peerId]);
460
+ `, [this.ownerPublicKey, signature, peerId]);
202
461
 
203
462
  log('Peer %s removed successfully', peerId);
204
463
  }
205
464
 
465
+ /**
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.
470
+ *
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).
481
+ *
482
+ * @param peerId - the membership row to re-touch.
483
+ * @param updatedAt - the strictly-increasing freshness stamp to write.
484
+ * @throws if no owner private key is configured (a non-owner cannot
485
+ * re-sign another peer's row) or the control database is not initialized.
486
+ */
487
+ async reauthorizePeer(peerId: string, updatedAt: number): Promise<void> {
488
+ // Fail fast on a keyless service before any DB read (see removePeer): a non-owner
489
+ // cannot re-sign the voucher, and must not silently no-op on an absent row.
490
+ this.requireOwnerPrivateKey();
491
+ if (!this.controlDatabase) {
492
+ throw new Error('Control database not initialized');
493
+ }
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) {
505
+ log('reauthorizePeer: no CadrePeer row for %s (nothing to re-touch)', peerId);
506
+ return;
507
+ }
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
+ log('Peer %s re-authorized (UpdatedAt=%d) for write-while-alone re-replication', peerId, updatedAt);
517
+ }
518
+
206
519
  /**
207
520
  * Create a seed from the current control network state.
208
- * The seed contains peer information and is signed by an authority.
521
+ * The seed contains peer information and is signed by an owner.
209
522
  */
210
523
  async createSeed(): Promise<ControlNetworkSeed> {
211
- if (!this.config.authorityPrivateKey || !this.authorityPublicKey) {
212
- throw new Error('Authority key required to create seeds');
524
+ if (!this.config.ownerPrivateKey || !this.ownerPublicKey) {
525
+ throw new Error('Owner key required to create seeds');
213
526
  }
214
527
 
215
528
  if (!this.controlDatabase || !this.libp2pNode) {
@@ -227,12 +540,12 @@ export class SeedBootstrapService {
227
540
  peers,
228
541
  };
229
542
 
230
- // Sign the seed
231
- const seedJson = JSON.stringify(seedData);
232
- const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url') as string;
543
+ // Sign the seed over its canonical byte representation
544
+ const seedJson = canonicalSeedPayload(seedData);
545
+ const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
233
546
  const signature = sign(
234
547
  seedDigest,
235
- this.config.authorityPrivateKey,
548
+ this.config.ownerPrivateKey,
236
549
  'ed25519',
237
550
  'base64url',
238
551
  'base64url',
@@ -242,7 +555,7 @@ export class SeedBootstrapService {
242
555
  const seed: ControlNetworkSeed = {
243
556
  ...seedData,
244
557
  signature,
245
- signerKey: this.authorityPublicKey,
558
+ signerKey: this.ownerPublicKey,
246
559
  };
247
560
 
248
561
  log('Created seed with %d peers', peers.length);
@@ -251,9 +564,23 @@ export class SeedBootstrapService {
251
564
 
252
565
  /**
253
566
  * Apply a seed to populate the peer cache and enable connections.
254
- * Validates the seed signature before applying.
567
+ *
568
+ * Validates the seed signature, then evaluates a trust anchor for the
569
+ * `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.
574
+ *
575
+ * @param seed - The seed to apply (already transport-decoded).
576
+ * @param options.trustPolicy - Per-call policy override (e.g. a
577
+ * `pinnedKeyTrustPolicy` derived from a `CadreInvite`) used instead of the
578
+ * service-configured default for this seed only.
255
579
  */
256
- async applySeed(seed: ControlNetworkSeed): Promise<ApplySeedResult> {
580
+ async applySeed(
581
+ seed: ControlNetworkSeed,
582
+ options?: { trustPolicy?: SeedTrustPolicy }
583
+ ): Promise<ApplySeedResult> {
257
584
  if (!this.libp2pNode) {
258
585
  return { success: false, peersAdded: 0, error: 'Service not initialized' };
259
586
  }
@@ -265,12 +592,24 @@ export class SeedBootstrapService {
265
592
  return { success: false, peersAdded: 0, error: 'Invalid seed signature' };
266
593
  }
267
594
 
268
- // Verify the signer's key matches an authority peer's public key
269
- const signerIsAuthority = seed.peers.some(
270
- p => p.isAuthority && p.publicKey === seed.signerKey
271
- );
272
- if (!signerIsAuthority) {
273
- return { success: false, peersAdded: 0, error: 'Signer key does not match any authority peer' };
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>();
601
+ const policy = options?.trustPolicy ?? this.trustPolicy;
602
+ const decision = await policy.evaluate({
603
+ partyId: seed.partyId,
604
+ signerKey: seed.signerKey,
605
+ knownOwnerKeys,
606
+ });
607
+ if (!decision.trusted) {
608
+ return {
609
+ success: false,
610
+ peersAdded: 0,
611
+ error: decision.reason ?? 'Signer key not trusted by trust policy',
612
+ };
274
613
  }
275
614
 
276
615
  let peersAdded = 0;
@@ -295,13 +634,13 @@ export class SeedBootstrapService {
295
634
  }
296
635
  }
297
636
 
298
- // Dial authority peers to establish connections
299
- for (const peer of seed.peers.filter(p => p.isAuthority)) {
637
+ // Dial owner peers to establish connections
638
+ for (const peer of seed.peers.filter(p => p.isOwner)) {
300
639
  try {
301
640
  if (peer.multiaddrs.length > 0) {
302
641
  const addr = multiaddr(peer.multiaddrs[0]);
303
642
 
304
- log('Dialing authority peer: %s', peer.peerId);
643
+ log('Dialing owner peer: %s', peer.peerId);
305
644
  await this.libp2pNode.dial(addr);
306
645
  }
307
646
  } catch (error) {
@@ -345,26 +684,18 @@ export class SeedBootstrapService {
345
684
 
346
685
  // Dial the target and open a stream
347
686
  const rawStream = await this.libp2pNode.dialProtocol(addr, SEED_PROTOCOL);
348
- const stream = rawStream as unknown as LibP2PStream;
687
+ const stream = rawStream as unknown as ControlStream;
349
688
 
350
689
  try {
351
- // Send the seed message
690
+ // Send the seed message as one length-prefixed frame (shared writer).
352
691
  const message: SeedMessage = {
353
692
  partyId: seed.partyId,
354
693
  peers: seed.peers,
355
- transactions: seed.transactions,
356
694
  signature: seed.signature,
357
695
  signerKey: seed.signerKey,
358
696
  };
359
697
 
360
- const messageBytes = new TextEncoder().encode(JSON.stringify(message));
361
-
362
- // Write length-prefixed message using libp2p 3.x send() API
363
- const lengthBytes = new Uint8Array(4);
364
- new DataView(lengthBytes.buffer).setUint32(0, messageBytes.length, false);
365
-
366
- stream.send(lengthBytes);
367
- stream.send(messageBytes);
698
+ writeFrame(stream, message);
368
699
 
369
700
  // In libp2p v3.x, close() closes the write end only (signals EOF),
370
701
  // while the read end remains open for receiving the ack.
@@ -385,8 +716,8 @@ export class SeedBootstrapService {
385
716
  }
386
717
 
387
718
  // Parse length-prefixed response
388
- const responseLength = new DataView(responseData.buffer, responseData.byteOffset).getUint32(0, false);
389
- const responseJson = new TextDecoder().decode(responseData.slice(4, 4 + responseLength));
719
+ const responseBody = decodeLengthPrefixedFrame(responseData);
720
+ const responseJson = new TextDecoder().decode(responseBody);
390
721
  const ack = JSON.parse(responseJson) as SeedAckMessage;
391
722
 
392
723
  log('Seed delivery response: accepted=%s', ack.accepted);
@@ -420,15 +751,11 @@ export class SeedBootstrapService {
420
751
  */
421
752
  validateSeedSignature(seed: ControlNetworkSeed): boolean {
422
753
  try {
423
- // Reconstruct the signed data (seed without signature fields)
424
- const seedData = {
425
- partyId: seed.partyId,
426
- peers: seed.peers,
427
- ...(seed.transactions ? { transactions: seed.transactions } : {}),
428
- };
429
-
430
- const seedJson = JSON.stringify(seedData);
431
- const seedDigest = digest(seedJson, 'sha256', 'utf8', 'base64url') as string;
754
+ // Reconstruct the signed bytes via the shared canonical payload builder so
755
+ // verification is independent of key order. The payload is the fixed
756
+ // `{ partyId, peers }` the producer emits.
757
+ const seedJson = canonicalSeedPayload(seed);
758
+ const seedDigest = digest([seedJson], 'sha256', 'base64url') as string;
432
759
 
433
760
  return verify(
434
761
  seedDigest,
@@ -447,34 +774,39 @@ export class SeedBootstrapService {
447
774
 
448
775
  /**
449
776
  * Query peers from the control database.
777
+ *
778
+ * Owner identity is sourced from the `OwnerKey` table, not from the
779
+ * transport peer ID. An Ed25519 libp2p PeerId embeds its public key (identity
780
+ * multihash), so each peer's ed25519 key is derivable from its `PeerId`; a
781
+ * peer is an owner iff that derived key is in the `OwnerKey` set.
782
+ * This makes any owner node markable — not just the local one — and ties
783
+ * `isOwner` to the control table rather than to `peerId === self`.
450
784
  */
451
785
  private async queryPeers(): Promise<SeedPeer[]> {
452
786
  if (!this.controlDatabase) {
453
787
  return [];
454
788
  }
455
789
 
790
+ const ownerKeys = await this.controlDatabase.getOwnerKeys();
456
791
  const db = this.controlDatabase.getDatabase();
457
792
  const peers: SeedPeer[] = [];
458
793
 
459
- // First, get all authority keys
460
- const authorityKeys = new Set<string>();
461
- for await (const row of db.eval('select Key from CadreControl.AuthorityKey')) {
462
- authorityKeys.add(row.Key as string);
463
- }
464
-
465
794
  // Query CadrePeer table
466
795
  for await (const row of db.eval('select PeerId, Multiaddr from CadreControl.CadrePeer')) {
467
796
  const peerId = row.PeerId as string;
468
797
  const multiaddr = row.Multiaddr as string | null;
469
798
 
470
- // Mark peer as authority if it matches the service's own peer ID
471
- const isAuthority = peerId === this.libp2pNode?.peerId.toString();
799
+ // Derive the peer's ed25519 key from its PeerId; a non-Ed25519 peer or an
800
+ // unparsable id yields null and is treated as a non-owner rather than
801
+ // failing the whole seed creation.
802
+ const pubKeyB64 = ed25519PublicKeyB64FromPeerId(peerId);
803
+ const isOwner = pubKeyB64 !== null && ownerKeys.has(pubKeyB64);
472
804
 
473
805
  peers.push({
474
806
  peerId,
475
807
  multiaddrs: multiaddr ? multiaddr.split(',') : [],
476
- isAuthority,
477
- ...(isAuthority && this.authorityPublicKey ? { publicKey: this.authorityPublicKey } : {}),
808
+ isOwner,
809
+ ...(isOwner ? { publicKey: pubKeyB64 } : {}),
478
810
  });
479
811
  }
480
812
 
@@ -482,105 +814,106 @@ export class SeedBootstrapService {
482
814
  }
483
815
 
484
816
  /**
485
- * Register the seed protocol handler.
817
+ * Register the seed protocol handler. The inbound closure just delegates to
818
+ * {@link handleSeedStream} — extracted as a method so it has a unit-test seam
819
+ * (mirroring wake's `handleStream`) the inline closure never had.
486
820
  */
487
821
  private registerProtocolHandler(): void {
488
822
  if (!this.libp2pNode) return;
489
823
 
490
- this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
491
- const stream = rawStream as LibP2PStream;
824
+ void this.libp2pNode.handle(SEED_PROTOCOL, async (rawStream: unknown, rawConnection: unknown) => {
492
825
  const remotePeerId = (rawConnection as Connection).remotePeer.toString();
493
- log('Incoming seed delivery from: %s', remotePeerId);
826
+ await this.handleSeedStream(rawStream as ControlStream, remotePeerId);
827
+ });
828
+
829
+ log('Registered seed protocol handler: %s', SEED_PROTOCOL);
830
+ }
831
+
832
+ /**
833
+ * Read one inbound seed frame, apply it, and write the ack.
834
+ *
835
+ * Hardened against a buggy/compromised own-cadre node: a concurrency cap (over
836
+ * {@link maxConcurrentSeeds}, reply without applying), a read timeout (a peer
837
+ * that never half-closes is aborted inside `readStreamToEnd`), and the existing
838
+ * malformed/oversized-frame guard — all reported as a non-accepting
839
+ * {@link SeedAckMessage} rather than a dropped/hung stream.
840
+ */
841
+ private async handleSeedStream(stream: ControlStream, remotePeerId: string): Promise<void> {
842
+ log('Incoming seed delivery from: %s', remotePeerId);
494
843
 
844
+ if (this.activeStreams >= this.maxConcurrentSeeds) {
845
+ log('Rejecting seed from %s: %d concurrent streams at cap %d', remotePeerId, this.activeStreams, this.maxConcurrentSeeds);
846
+ const ack: SeedAckMessage = { accepted: false, reason: 'Too many concurrent seed deliveries' };
495
847
  try {
496
- // Read the seed message (stream is AsyncIterable in libp2p 3.x)
497
- const chunks: Uint8Array[] = [];
498
- let totalLength = 0;
499
-
500
- for await (const chunk of stream) {
501
- const bytes = chunk instanceof Uint8Array ? chunk : (chunk as { subarray(): Uint8Array }).subarray();
502
- chunks.push(bytes);
503
- totalLength += bytes.length;
504
- if (totalLength > MAX_SEED_SIZE) {
505
- throw new Error('Seed message too large');
506
- }
507
- }
848
+ writeFrame(stream, ack);
849
+ } catch {
850
+ // Ignore send errors on the reject path.
851
+ }
852
+ try {
853
+ await stream.close();
854
+ } catch {
855
+ // Ignore close errors.
856
+ }
857
+ return;
858
+ }
508
859
 
509
- const data = new Uint8Array(totalLength);
510
- let offset = 0;
511
- for (const chunk of chunks) {
512
- data.set(chunk, offset);
513
- offset += chunk.length;
514
- }
860
+ this.activeStreams++;
861
+ try {
862
+ // Read the seed frame to EOF (bounded + size-capped), then decode it.
863
+ const data = await readStreamToEnd(stream, {
864
+ maxBytes: MAX_SEED_SIZE,
865
+ timeoutMs: this.seedReadTimeoutMs,
866
+ label: 'Seed',
867
+ });
515
868
 
516
- // Parse length-prefixed message
517
- const messageLength = new DataView(data.buffer, data.byteOffset).getUint32(0, false);
518
- const messageJson = new TextDecoder().decode(data.slice(4, 4 + messageLength));
519
- const message = JSON.parse(messageJson) as SeedMessage;
520
-
521
- // Emit seed received event
522
- this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
523
-
524
- // Convert to seed and apply
525
- const seed: ControlNetworkSeed = {
526
- partyId: message.partyId,
527
- peers: message.peers,
528
- transactions: message.transactions,
529
- signature: message.signature,
530
- signerKey: message.signerKey,
531
- };
532
-
533
- const result = await this.applySeed(seed);
534
-
535
- // Emit appropriate event based on result
536
- if (result.success) {
537
- this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
538
- } else {
539
- this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
540
- }
869
+ const messageBody = decodeLengthPrefixedFrame(data);
870
+ const messageJson = new TextDecoder().decode(messageBody);
871
+ const message = JSON.parse(messageJson) as SeedMessage;
541
872
 
542
- // Send acknowledgment using libp2p 3.x send() API
543
- const ack: SeedAckMessage = {
544
- accepted: result.success,
545
- reason: result.error,
546
- };
873
+ // Emit seed received event
874
+ this.eventCallbacks.onSeedReceived?.(message.partyId, remotePeerId);
547
875
 
548
- const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
549
- const lengthBytes = new Uint8Array(4);
550
- new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
876
+ // Convert to seed and apply
877
+ const seed: ControlNetworkSeed = {
878
+ partyId: message.partyId,
879
+ peers: message.peers,
880
+ signature: message.signature,
881
+ signerKey: message.signerKey,
882
+ };
551
883
 
552
- stream.send(lengthBytes);
553
- stream.send(ackBytes);
884
+ const result = await this.applySeed(seed);
554
885
 
555
- } catch (error) {
556
- log('Error handling seed delivery: %o', error);
557
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
558
-
559
- // Emit error event
560
- this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
561
-
562
- // Send error acknowledgment
563
- const ack: SeedAckMessage = {
564
- accepted: false,
565
- reason: errorMessage,
566
- };
567
-
568
- const ackBytes = new TextEncoder().encode(JSON.stringify(ack));
569
- const lengthBytes = new Uint8Array(4);
570
- new DataView(lengthBytes.buffer).setUint32(0, ackBytes.length, false);
571
-
572
- try {
573
- stream.send(lengthBytes);
574
- stream.send(ackBytes);
575
- } catch {
576
- // Ignore send errors
577
- }
578
- } finally {
579
- await stream.close();
886
+ // Emit appropriate event based on result
887
+ if (result.success) {
888
+ this.eventCallbacks.onSeedApplied?.(seed.partyId, result.peersAdded);
889
+ } else {
890
+ this.eventCallbacks.onSeedError?.(seed.partyId, result.error ?? 'Unknown error');
580
891
  }
581
- });
582
892
 
583
- log('Registered seed protocol handler: %s', SEED_PROTOCOL);
893
+ const ack: SeedAckMessage = { accepted: result.success, reason: result.error };
894
+ writeFrame(stream, ack);
895
+ } catch (error) {
896
+ log('Error handling seed delivery: %o', error);
897
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
898
+
899
+ // Emit error event
900
+ this.eventCallbacks.onSeedError?.(this.config.partyId, errorMessage);
901
+
902
+ // Send error acknowledgment (best-effort on the still-open write side).
903
+ const ack: SeedAckMessage = { accepted: false, reason: errorMessage };
904
+ try {
905
+ writeFrame(stream, ack);
906
+ } catch {
907
+ // Ignore send errors
908
+ }
909
+ } finally {
910
+ this.activeStreams--;
911
+ try {
912
+ await stream.close();
913
+ } catch {
914
+ // Ignore close errors.
915
+ }
916
+ }
584
917
  }
585
918
 
586
919
  /**
@@ -652,22 +985,29 @@ export class SeedBootstrapService {
652
985
  // configured (typically by `@serfab/cadre-host`'s NatService), it takes
653
986
  // priority — it may substitute a DDNS hostname and externally-mapped
654
987
  // port for the raw LAN multiaddrs libp2p reports.
655
- let authorityAddrs: string[];
988
+ let ownerAddrs: string[];
656
989
  if (this.config.inviteAddressResolver) {
657
990
  try {
658
- authorityAddrs = await this.config.inviteAddressResolver();
991
+ ownerAddrs = await this.config.inviteAddressResolver();
659
992
  } catch (err) {
660
993
  log('inviteAddressResolver threw, falling back to libp2pNode.getMultiaddrs(): %o', err);
661
- authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
994
+ ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
662
995
  }
663
996
  } else {
664
- authorityAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
997
+ ownerAddrs = this.libp2pNode.getMultiaddrs().map(a => a.toString());
665
998
  }
666
999
 
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
+ : [];
1005
+
667
1006
  const now = Date.now();
668
1007
  const invite: CadreInvite = {
669
1008
  partyId: this.config.partyId,
670
- authorityAddrs,
1009
+ ownerAddrs,
1010
+ ownerKeys: ownerKeys.length ? ownerKeys : undefined,
671
1011
  token,
672
1012
  createdAt: now,
673
1013
  expiresAt: expiresIn ? now + expiresIn : undefined,
@@ -675,7 +1015,7 @@ export class SeedBootstrapService {
675
1015
 
676
1016
  const encodedInvite = this.encodeInvite(invite);
677
1017
 
678
- log('Invite created with %d authority addresses', authorityAddrs.length);
1018
+ log('Invite created with %d owner addresses, %d owner keys', ownerAddrs.length, ownerKeys.length);
679
1019
 
680
1020
  return { invite, encodedInvite };
681
1021
  }
@@ -769,11 +1109,11 @@ export class SeedBootstrapService {
769
1109
  }
770
1110
 
771
1111
  /**
772
- * Dial an authority from an invite.
773
- * Use this on a phone after receiving an invite to connect to the authority.
1112
+ * Dial an owner from an invite.
1113
+ * Use this on a phone after receiving an invite to connect to the owner.
774
1114
  *
775
1115
  * @param invite - The invite received out-of-band
776
- * @returns Connection to the authority
1116
+ * @returns Connection to the owner
777
1117
  */
778
1118
  async dialInvite(invite: CadreInvite): Promise<void> {
779
1119
  if (!this.libp2pNode) {
@@ -785,15 +1125,15 @@ export class SeedBootstrapService {
785
1125
  throw new Error('Invite has expired');
786
1126
  }
787
1127
 
788
- log('Dialing invite authority with %d addresses', invite.authorityAddrs.length);
1128
+ log('Dialing invite owner with %d addresses', invite.ownerAddrs.length);
789
1129
 
790
- // Try each authority address until one succeeds
1130
+ // Try each owner address until one succeeds
791
1131
  let lastError: Error | null = null;
792
- for (const addrStr of invite.authorityAddrs) {
1132
+ for (const addrStr of invite.ownerAddrs) {
793
1133
  try {
794
1134
  const addr = multiaddr(addrStr);
795
1135
  await this.libp2pNode.dial(addr);
796
- log('Connected to authority at: %s', addrStr);
1136
+ log('Connected to owner at: %s', addrStr);
797
1137
  return;
798
1138
  } catch (error) {
799
1139
  lastError = error instanceof Error ? error : new Error(String(error));
@@ -801,7 +1141,7 @@ export class SeedBootstrapService {
801
1141
  }
802
1142
  }
803
1143
 
804
- throw lastError ?? new Error('No authority addresses available');
1144
+ throw lastError ?? new Error('No owner addresses available');
805
1145
  }
806
1146
  }
807
1147