@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.
- package/README.md +48 -1
- package/dist/client.d.ts +21 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +73 -11
- package/dist/client.js.map +1 -1
- package/dist/connection-file.d.ts +6 -1
- package/dist/connection-file.d.ts.map +1 -1
- package/dist/connection-file.js +32 -10
- package/dist/connection-file.js.map +1 -1
- package/dist/envelope.d.ts +11 -1
- package/dist/envelope.d.ts.map +1 -1
- package/dist/envelope.js +23 -7
- package/dist/envelope.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts +3 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +17 -8
- package/dist/provider.js.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +88 -8
- package/src/connection-file.ts +37 -10
- package/src/envelope.ts +32 -13
- package/src/index.ts +1 -0
- package/src/provider.ts +19 -7
package/src/connection-file.ts
CHANGED
|
@@ -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
|
|
4
|
-
// exactly as the Rust reader does, so 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,
|
|
68
|
-
*
|
|
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
|
-
|
|
76
|
+
function verifyOwnerOnly(path: string, stat: { uid: number; mode: number }, expectedUid: number | undefined): void {
|
|
73
77
|
if (process.platform === "win32") return;
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
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.
|
|
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)
|
|
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(
|
|
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.
|
|
1418
|
-
this.finishClosed();
|
|
1430
|
+
this.liveRoutes.clear();
|
|
1419
1431
|
}
|
|
1420
1432
|
|
|
1421
1433
|
private finishClosed(): void {
|