Skip to content

Commit fa2272a

Browse files
authored
Remove node forge dependency (coder#648)
Replaced `node-forge` with `@peculiar/x509` (more modern, lightweight, and widely adopted). Node.js's built-in `crypto` module was attempted first, but `keyUsage` returns `undefined`. **Testing in Electron** All vitest tests now run in Electron to mirror VS Code's environment. This adds a few seconds overhead vs Node.js. `electron` was added as dev dependency for BoringSSL compatibility (Node.js uses OpenSSL).
1 parent ebbbb9a commit fa2272a

File tree

6 files changed

+535
-89
lines changed

6 files changed

+535
-89
lines changed

.vscode/settings.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,9 @@
1010
},
1111
"[jsonc]": {
1212
"editor.defaultFormatter": "esbenp.prettier-vscode"
13-
}
13+
},
14+
"vitest.nodeEnv": {
15+
"ELECTRON_RUN_AS_NODE": "1"
16+
},
17+
"vitest.nodeExecutable": "node_modules/.bin/electron"
1418
}

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
"package": "webpack --mode production --devtool hidden-source-map",
2626
"package:prerelease": "npx vsce package --pre-release",
2727
"pretest": "tsc -p . --outDir out && tsc -p test --outDir out && yarn run build && yarn run lint",
28-
"test": "vitest",
28+
"test": "ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs",
2929
"test:ci": "CI=true yarn test",
3030
"test:integration": "vscode-test",
3131
"vscode:prepublish": "yarn package",
@@ -343,12 +343,12 @@
343343
"word-wrap": "1.2.5"
344344
},
345345
"dependencies": {
346+
"@peculiar/x509": "^1.14.0",
346347
"axios": "1.12.2",
347348
"date-fns": "^3.6.0",
348349
"eventsource": "^3.0.6",
349350
"find-process": "https://github.com/coder/find-process#fix/sequoia-compat",
350351
"jsonc-parser": "^3.3.1",
351-
"node-forge": "^1.3.1",
352352
"openpgp": "^6.2.2",
353353
"pretty-bytes": "^7.1.0",
354354
"proxy-agent": "^6.5.0",
@@ -361,7 +361,6 @@
361361
"@types/eventsource": "^3.0.0",
362362
"@types/glob": "^7.1.3",
363363
"@types/node": "^22.14.1",
364-
"@types/node-forge": "^1.3.14",
365364
"@types/semver": "^7.7.1",
366365
"@types/ua-parser-js": "0.7.36",
367366
"@types/vscode": "^1.73.0",
@@ -375,6 +374,7 @@
375374
"bufferutil": "^4.0.9",
376375
"coder": "https://github.com/coder/coder#main",
377376
"dayjs": "^1.11.13",
377+
"electron": "^39.1.2",
378378
"eslint": "^8.57.1",
379379
"eslint-config-prettier": "^10.1.8",
380380
"eslint-import-resolver-typescript": "^4.4.4",

src/error.ts

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import {
2+
X509Certificate,
3+
KeyUsagesExtension,
4+
KeyUsageFlags,
5+
} from "@peculiar/x509";
16
import { isAxiosError } from "axios";
27
import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors";
3-
import * as forge from "node-forge";
4-
import * as tls from "tls";
8+
import * as tls from "node:tls";
59
import * as vscode from "vscode";
610

711
import { type Logger } from "./logging/logger";
@@ -23,10 +27,6 @@ export enum X509_ERR {
2327
UNTRUSTED_CHAIN = "Your Coder deployment's certificate chain does not appear to be trusted by this system. The root of the certificate chain must be added to this system's trust store. ",
2428
}
2529

26-
interface KeyUsage {
27-
keyCertSign: boolean;
28-
}
29-
3030
export class CertificateError extends Error {
3131
public static ActionAllowInsecure = "Allow Insecure";
3232
public static ActionOK = "OK";
@@ -80,7 +80,7 @@ export class CertificateError extends Error {
8080
const url = new URL(address);
8181
const socket = tls.connect(
8282
{
83-
port: parseInt(url.port, 10) || 443,
83+
port: Number.parseInt(url.port, 10) || 443,
8484
host: url.hostname,
8585
rejectUnauthorized: false,
8686
},
@@ -91,29 +91,27 @@ export class CertificateError extends Error {
9191
throw new Error("no peer certificate");
9292
}
9393

94-
// We use node-forge for two reasons:
95-
// 1. Node/Electron only provide extended key usage.
96-
// 2. Electron's checkIssued() will fail because it suffers from same
97-
// the key usage bug that we are trying to work around here in the
98-
// first place.
99-
const cert = forge.pki.certificateFromPem(x509.toString());
100-
if (!cert.issued(cert)) {
94+
// We use "@peculiar/x509" because Node's x509 returns an undefined `keyUsage`.
95+
const cert = new X509Certificate(x509.toString());
96+
const isSelfIssued = cert.subject === cert.issuer;
97+
if (!isSelfIssued) {
10198
return resolve(X509_ERR.PARTIAL_CHAIN);
10299
}
103100

104101
// The key usage needs to exist but not have cert signing to fail.
105-
const keyUsage = cert.getExtension({ name: "keyUsage" }) as
106-
| KeyUsage
107-
| undefined;
108-
if (keyUsage && !keyUsage.keyCertSign) {
109-
return resolve(X509_ERR.NON_SIGNING);
110-
} else {
111-
// This branch is currently untested; it does not appear possible to
112-
// get the error "unable to verify" with a self-signed certificate
113-
// unless the key usage was the issue since it would have errored
114-
// with "self-signed certificate" instead.
115-
return resolve(X509_ERR.UNTRUSTED_LEAF);
102+
const extension = cert.getExtension(KeyUsagesExtension);
103+
if (extension) {
104+
const hasKeyCertSign =
105+
extension.usages & KeyUsageFlags.keyCertSign;
106+
if (!hasKeyCertSign) {
107+
return resolve(X509_ERR.NON_SIGNING);
108+
}
116109
}
110+
// This branch is currently untested; it does not appear possible to
111+
// get the error "unable to verify" with a self-signed certificate
112+
// unless the key usage was the issue since it would have errored
113+
// with "self-signed certificate" instead.
114+
return resolve(X509_ERR.UNTRUSTED_LEAF);
117115
},
118116
);
119117
socket.on("error", reject);

src/remote/remote.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,7 @@ export class Remote {
344344
} catch (error) {
345345
subscription.dispose();
346346
reject(error);
347+
return;
347348
} finally {
348349
inProgress = false;
349350
}

test/unit/error.test.ts

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import {
2+
KeyUsagesExtension,
3+
X509Certificate as X509CertificatePeculiar,
4+
} from "@peculiar/x509";
15
import axios from "axios";
2-
import * as fs from "fs/promises";
3-
import https from "https";
6+
import { X509Certificate as X509CertificateNode } from "node:crypto";
7+
import * as fs from "node:fs/promises";
8+
import https from "node:https";
49
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
510

611
import { CertificateError, X509_ERR, X509_ERR_CODE } from "@/error";
@@ -12,14 +17,11 @@ describe("Certificate errors", () => {
1217
// Before each test we make a request to sanity check that we really get the
1318
// error we are expecting, then we run it through CertificateError.
1419

15-
// TODO: These sanity checks need to be ran in an Electron environment to
16-
// reflect real usage in VS Code. We should either revert back to the standard
17-
// extension testing framework which I believe runs in a headless VS Code
18-
// instead of using vitest or at least run the tests through Electron running as
19-
// Node (for now I do this manually by shimming Node).
20-
const isElectron =
21-
(process.versions.electron || process.env.ELECTRON_RUN_AS_NODE) &&
22-
!process.env.VSCODE_PID; // Running from the test explorer in VS Code
20+
// These tests run in Electron (BoringSSL) for accurate certificate validation testing.
21+
22+
it("should run in Electron environment", () => {
23+
expect(process.versions.electron).toBeTruthy();
24+
});
2325

2426
beforeAll(() => {
2527
vi.mock("vscode", () => {
@@ -114,8 +116,7 @@ describe("Certificate errors", () => {
114116
});
115117

116118
// In Electron a self-issued certificate without the signing capability fails
117-
// (again with the same "unable to verify" error) but in Node self-issued
118-
// certificates are not required to have the signing capability.
119+
// (again with the same "unable to verify" error)
119120
it("detects self-signed certificates without signing capability", async () => {
120121
const address = await startServer("no-signing");
121122
const request = axios.get(address, {
@@ -124,26 +125,16 @@ describe("Certificate errors", () => {
124125
servername: "localhost",
125126
}),
126127
});
127-
if (isElectron) {
128-
await expect(request).rejects.toHaveProperty(
129-
"code",
130-
X509_ERR_CODE.UNABLE_TO_VERIFY_LEAF_SIGNATURE,
131-
);
132-
try {
133-
await request;
134-
} catch (error) {
135-
const wrapped = await CertificateError.maybeWrap(
136-
error,
137-
address,
138-
logger,
139-
);
140-
expect(wrapped instanceof CertificateError).toBeTruthy();
141-
expect((wrapped as CertificateError).x509Err).toBe(
142-
X509_ERR.NON_SIGNING,
143-
);
144-
}
145-
} else {
146-
await expect(request).resolves.toHaveProperty("data", "foobar");
128+
await expect(request).rejects.toHaveProperty(
129+
"code",
130+
X509_ERR_CODE.UNABLE_TO_VERIFY_LEAF_SIGNATURE,
131+
);
132+
try {
133+
await request;
134+
} catch (error) {
135+
const wrapped = await CertificateError.maybeWrap(error, address, logger);
136+
expect(wrapped instanceof CertificateError).toBeTruthy();
137+
expect((wrapped as CertificateError).x509Err).toBe(X509_ERR.NON_SIGNING);
147138
}
148139
});
149140

@@ -157,6 +148,24 @@ describe("Certificate errors", () => {
157148
await expect(request).resolves.toHaveProperty("data", "foobar");
158149
});
159150

151+
// Node's X509Certificate.keyUsage is unreliable, so use a third-party parser
152+
it("parses no-signing cert keyUsage with third-party library", async () => {
153+
const certPem = await fs.readFile(
154+
getFixturePath("tls", "no-signing.crt"),
155+
"utf-8",
156+
);
157+
158+
// Node's implementation seems to always return `undefined`
159+
const nodeCert = new X509CertificateNode(certPem);
160+
expect(nodeCert.keyUsage).toBeUndefined();
161+
162+
// Here we can correctly get the KeyUsages
163+
const peculiarCert = new X509CertificatePeculiar(certPem);
164+
const extension = peculiarCert.getExtension(KeyUsagesExtension);
165+
expect(extension).toBeDefined();
166+
expect(extension?.usages).toBeTruthy();
167+
});
168+
160169
// Both environments give the same error code when a self-issued certificate is
161170
// untrusted.
162171
it("detects self-signed certificates", async () => {

0 commit comments

Comments
 (0)