@cortexkit/subc-client 0.20.0 → 0.21.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.
@@ -1,8 +1,8 @@
1
1
  // Port of subc-transport's connection_file.rs reader. The connection file is
2
2
  // the daemon's published rendezvous record; its `key` is the shared transport
3
- // secret. We refuse to trust a key from a file other local users can read,
4
- // exactly as the Rust reader does, so a leaked (group/world-readable) key is a
5
- // loud failure rather than a silent downgrade.
3
+ // secret. We refuse to trust a key from a file another local user owns or can
4
+ // read, exactly as the Rust reader does, so a substituted or leaked
5
+ // (group/world-readable) key is a loud failure rather than a silent downgrade.
6
6
 
7
7
  import { promises as fs } from "node:fs";
8
8
 
@@ -64,14 +64,22 @@ function validate(info: ConnectionInfo): void {
64
64
  }
65
65
 
66
66
  /**
67
- * On unix, reject any group/other permission bit: the key is published
68
- * owner-only (0600), so a wider mode means the secret has leaked. On Windows the
67
+ * On unix, refuse a file owned by anyone but `expectedUid`, then any
68
+ * group/other permission bit. A file another user owns is untrusted even at
69
+ * 0600: its owner could have written their own endpoint and key, and this
70
+ * client would then authenticate to them. A wider mode on our own file means the
71
+ * secret has leaked. `expectedUid` is undefined where the platform has no
72
+ * effective uid (Windows), and the owner check is skipped there. On Windows the
69
73
  * file inherits the per-user profile directory ACL at create time and there are
70
74
  * no portable mode bits to re-check, matching the Rust no-op.
71
75
  */
72
- async function verifyOwnerOnly(path: string): Promise<void> {
76
+ function verifyOwnerOnly(path: string, stat: { uid: number; mode: number }, expectedUid: number | undefined): void {
73
77
  if (process.platform === "win32") return;
74
- const stat = await fs.stat(path);
78
+ if (expectedUid !== undefined && stat.uid !== expectedUid) {
79
+ throw new ConnectionFileError(
80
+ `connection file ${path} is owned by uid ${stat.uid}, expected effective uid ${expectedUid}`,
81
+ );
82
+ }
75
83
  const mode = stat.mode & 0o777;
76
84
  if ((mode & 0o077) !== 0) {
77
85
  throw new ConnectionFileError(
@@ -80,10 +88,29 @@ async function verifyOwnerOnly(path: string): Promise<void> {
80
88
  }
81
89
  }
82
90
 
83
- /** Read, permission-check, and validate a connection file. */
91
+ /** Read, ownership- and permission-check, and validate a connection file. */
84
92
  export async function readConnectionFile(path: string): Promise<ConnectionInfo> {
85
- await verifyOwnerOnly(path);
86
- const raw = await fs.readFile(path, "utf8");
93
+ return readConnectionFileForUid(path, process.geteuid?.());
94
+ }
95
+
96
+ /**
97
+ * @internal `readConnectionFile` with the expected owner uid supplied by the
98
+ * caller, so tests can present a foreign owner without changing the process uid.
99
+ */
100
+ export async function readConnectionFileForUid(
101
+ path: string,
102
+ expectedUid: number | undefined,
103
+ ): Promise<ConnectionInfo> {
104
+ // Check the opened file, not a second lookup of the path: if the path were
105
+ // replaced between a stat and the read, an unchecked key would get through.
106
+ const handle = await fs.open(path, "r");
107
+ let raw: string;
108
+ try {
109
+ verifyOwnerOnly(path, await handle.stat(), expectedUid);
110
+ raw = await handle.readFile({ encoding: "utf8" });
111
+ } finally {
112
+ await handle.close();
113
+ }
87
114
  let parsed: Record<string, unknown>;
88
115
  try {
89
116
  parsed = JSON.parse(raw) as Record<string, unknown>;
package/src/envelope.ts CHANGED
@@ -172,18 +172,19 @@ function validateHeaderFields(header: EnvelopeHeader): void {
172
172
  "sheddable_illegal_frame_type",
173
173
  );
174
174
  }
175
- if (channel === 0 && epoch !== 0) {
176
- throw new DecodeError(
177
- `control channel carried nonzero epoch ${epoch}`,
178
- "nonzero_epoch_on_control_channel",
179
- );
180
- }
175
+ // Match the wire decoder's precedence when a header violates both rules.
181
176
  if (isPureHeader(ty) && len !== 0) {
182
177
  throw new DecodeError(
183
178
  `pure-header frame ${FrameType[ty]} declared non-zero body length ${len}`,
184
179
  "pure_header_frame_with_body",
185
180
  );
186
181
  }
182
+ if (channel === 0 && epoch !== 0) {
183
+ throw new DecodeError(
184
+ `control channel carried nonzero epoch ${epoch}`,
185
+ "nonzero_epoch_on_control_channel",
186
+ );
187
+ }
187
188
  }
188
189
 
189
190
  /** Decode and validate a header from the front of `bytes`. */
@@ -233,18 +234,19 @@ export function decodeHeader(bytes: Uint8Array): EnvelopeHeader {
233
234
  }
234
235
  const channel = view.getUint16(7, true);
235
236
  const epoch = view.getUint32(9, true);
236
- if (channel === 0 && epoch !== 0) {
237
- throw new DecodeError(
238
- `control channel carried nonzero epoch ${epoch}`,
239
- "nonzero_epoch_on_control_channel",
240
- );
241
- }
237
+ // Body length is checked before the control epoch in Rust and Swift too.
242
238
  if (isPureHeader(ty) && len !== 0) {
243
239
  throw new DecodeError(
244
240
  `pure-header frame ${FrameType[ty]} declared non-zero body length ${len}`,
245
241
  "pure_header_frame_with_body",
246
242
  );
247
243
  }
244
+ if (channel === 0 && epoch !== 0) {
245
+ throw new DecodeError(
246
+ `control channel carried nonzero epoch ${epoch}`,
247
+ "nonzero_epoch_on_control_channel",
248
+ );
249
+ }
248
250
  return { len, ver, ty, flags, channel, epoch, corr: view.getBigUint64(13, true) };
249
251
  }
250
252
 
@@ -278,7 +280,17 @@ export function buildFrameWithVersion(
278
280
  return { header, body };
279
281
  }
280
282
 
281
- /** Encode a frame to wire bytes: header followed by exactly `len` body bytes. */
283
+ /**
284
+ * Encode a frame to wire bytes: header followed by exactly `len` body bytes.
285
+ *
286
+ * Refuses, before producing any bytes, a frame the peer's decoder would reject:
287
+ * a header that fails any wire decode rule, or a body over MAX_FRAME_BODY_LEN.
288
+ * A `Frame` can be assembled by hand without going through `buildFrame`, and
289
+ * once a rejected header is on the socket the peer tears the whole connection
290
+ * down, so the refusal has to happen here rather than on the far side. The
291
+ * header check runs the encoded bytes through `decodeHeader` itself, so the
292
+ * writer cannot drift from the reader's rules.
293
+ */
282
294
  export function encodeFrame(frame: Frame): Uint8Array {
283
295
  if (frame.header.len !== frame.body.length) {
284
296
  throw new DecodeError(
@@ -286,7 +298,14 @@ export function encodeFrame(frame: Frame): Uint8Array {
286
298
  "frame_length_mismatch",
287
299
  );
288
300
  }
301
+ if (frame.body.length > MAX_FRAME_BODY_LEN) {
302
+ throw new DecodeError(
303
+ `frame body ${frame.body.length} exceeds max ${MAX_FRAME_BODY_LEN}`,
304
+ "frame_body_too_large",
305
+ );
306
+ }
289
307
  const header = encodeHeader(frame.header);
308
+ decodeHeader(header);
290
309
  const output = new Uint8Array(header.length + frame.body.length);
291
310
  output.set(header, 0);
292
311
  output.set(frame.body, header.length);
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export {
19
19
  type RouteTarget,
20
20
  type ManagedRouteKind,
21
21
  type ConsumerIdentity,
22
+ type RouteScope,
22
23
  type RouteOpenOptions,
23
24
  type CatalogCapabilities,
24
25
  type CatalogCapabilityRequirement,
package/src/provider.ts CHANGED
@@ -680,8 +680,7 @@ export class SubcProvider {
680
680
 
681
681
  async close(): Promise<void> {
682
682
  if (!this.closeStarted) {
683
- this.closeStarted = true;
684
- this.cancelRestoredDebounce();
683
+ this.endServing(new SubcProviderError("provider closed", "provider_closed"));
685
684
  const sock = this.sock;
686
685
  try {
687
686
  await sendFrame(sock, buildFrame(FrameType.Goodbye, controlFlags(), 0, 0, 0n, new Uint8Array(0)));
@@ -722,7 +721,9 @@ export class SubcProvider {
722
721
  const frame = await sock.readFrame(Number.POSITIVE_INFINITY, { afterHeaderMs: BODY_READ_TIMEOUT_MS });
723
722
  const keepGoing = await this.dispatch(frame, sock, generation, control);
724
723
  if (!keepGoing) {
725
- if (this.sock === sock && this.generation === generation) this.closeStarted = true;
724
+ if (this.sock === sock && this.generation === generation) {
725
+ this.endServing(new SubcProviderError("daemon closed provider connection", "connection_closed"));
726
+ }
726
727
  break;
727
728
  }
728
729
  }
@@ -903,12 +904,16 @@ export class SubcProvider {
903
904
 
904
905
  private abortGeneration(_generation: number): void {
905
906
  this.abortAllInflight();
907
+ this.rejectPendingRequests(new SubcProviderError("provider connection dropped", "connection_dropped"));
908
+ this.liveRoutes.clear();
909
+ }
910
+
911
+ private rejectPendingRequests(error: Error): void {
906
912
  for (const [key, pending] of this.pending) {
907
913
  this.pending.delete(key);
908
914
  clearTimeout(pending.timer);
909
- pending.reject(new SubcProviderError("provider connection dropped", "connection_dropped"));
915
+ pending.reject(error);
910
916
  }
911
- this.liveRoutes.clear();
912
917
  }
913
918
 
914
919
  private abortAllInflight(): void {
@@ -1410,12 +1415,19 @@ export class SubcProvider {
1410
1415
  }
1411
1416
 
1412
1417
  private failFatal(err: Error): void {
1418
+ this.endServing(err);
1419
+ this.sock.close();
1420
+ this.finishClosed();
1421
+ }
1422
+
1423
+ /** End work before closing the socket, which cannot settle requests on its own. */
1424
+ private endServing(err: Error): void {
1413
1425
  if (!this.closedErr) this.closedErr = err;
1414
1426
  this.closeStarted = true;
1415
1427
  this.cancelRestoredDebounce();
1428
+ this.rejectPendingRequests(this.closedErr);
1416
1429
  this.abortAllInflight();
1417
- this.sock.close();
1418
- this.finishClosed();
1430
+ this.liveRoutes.clear();
1419
1431
  }
1420
1432
 
1421
1433
  private finishClosed(): void {