@mnemom/mnemom 0.17.23 → 0.17.24
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/dist/rc-certs.mjs +186 -0
- package/dist/rc-proxy.mjs +49 -21
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Certificate checks and leaf rotation for the Remote Control MITM proxy (rc-proxy.mjs).
|
|
2
|
+
//
|
|
3
|
+
// The proxy mints its CA and leaf by shelling out to whatever `openssl` is first on PATH. On macOS
|
|
4
|
+
// that is often /usr/bin/openssl (LibreSSL), whose defaults differ from OpenSSL 3's: `x509 -req`
|
|
5
|
+
// signs with SHA-1, and EC keys come out with explicit curve parameters. Node (OpenSSL 3) refuses
|
|
6
|
+
// both — a SHA-1 leaf at https.createServer (ERR_SSL_CA_MD_TOO_WEAK), an explicit-parameter key at
|
|
7
|
+
// the client's chain verification. rc-proxy.mjs pins `-sha256` and `ec_param_enc:named_curve`;
|
|
8
|
+
// this module is the fail-closed check behind that pin, so an openssl that still mints something
|
|
9
|
+
// Node cannot use stops the proxy with a message naming the binary, not a raw Node stack trace.
|
|
10
|
+
//
|
|
11
|
+
// Node 22's X509Certificate has no signatureAlgorithm getter, and it reports `namedCurve` even for
|
|
12
|
+
// an explicit-parameter key, so the two properties are read from the certificate's DER directly.
|
|
13
|
+
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import tls from "node:tls";
|
|
17
|
+
import crypto from "node:crypto";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
|
|
20
|
+
const SIG_ALGS = {
|
|
21
|
+
"2a8648ce3d0401": "ecdsa-with-SHA1",
|
|
22
|
+
"2a8648ce3d040301": "ecdsa-with-SHA224",
|
|
23
|
+
"2a8648ce3d040302": "ecdsa-with-SHA256",
|
|
24
|
+
"2a8648ce3d040303": "ecdsa-with-SHA384",
|
|
25
|
+
"2a8648ce3d040304": "ecdsa-with-SHA512",
|
|
26
|
+
"2a864886f70d010104": "md5WithRSAEncryption",
|
|
27
|
+
"2a864886f70d010105": "sha1WithRSAEncryption",
|
|
28
|
+
"2a864886f70d01010b": "sha256WithRSAEncryption",
|
|
29
|
+
"2a864886f70d01010c": "sha384WithRSAEncryption",
|
|
30
|
+
"2a864886f70d01010d": "sha512WithRSAEncryption",
|
|
31
|
+
};
|
|
32
|
+
const STRONG_SIG_ALGS = new Set([
|
|
33
|
+
"ecdsa-with-SHA256",
|
|
34
|
+
"ecdsa-with-SHA384",
|
|
35
|
+
"ecdsa-with-SHA512",
|
|
36
|
+
"sha256WithRSAEncryption",
|
|
37
|
+
"sha384WithRSAEncryption",
|
|
38
|
+
"sha512WithRSAEncryption",
|
|
39
|
+
]);
|
|
40
|
+
const OID_EC_PUBLIC_KEY = "2a8648ce3d0201";
|
|
41
|
+
|
|
42
|
+
// One DER TLV at `off`: { tag, start (of the value), end }.
|
|
43
|
+
function tlv(buf, off) {
|
|
44
|
+
if (off + 2 > buf.length) throw new Error("truncated DER");
|
|
45
|
+
const tag = buf[off];
|
|
46
|
+
let len = buf[off + 1];
|
|
47
|
+
let start = off + 2;
|
|
48
|
+
if (len & 0x80) {
|
|
49
|
+
const n = len & 0x7f;
|
|
50
|
+
if (n < 1 || n > 4 || start + n > buf.length) throw new Error("bad DER length");
|
|
51
|
+
len = 0;
|
|
52
|
+
for (let i = 0; i < n; i++) len = len * 256 + buf[start + i];
|
|
53
|
+
start += n;
|
|
54
|
+
}
|
|
55
|
+
const end = start + len;
|
|
56
|
+
if (end > buf.length) throw new Error("truncated DER");
|
|
57
|
+
return { tag, start, end };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function children(buf, node) {
|
|
61
|
+
const out = [];
|
|
62
|
+
for (let o = node.start; o < node.end; ) {
|
|
63
|
+
const c = tlv(buf, o);
|
|
64
|
+
out.push(c);
|
|
65
|
+
o = c.end;
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const hex = (buf, node) => buf.subarray(node.start, node.end).toString("hex");
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The signature algorithm and EC parameter encoding of a certificate, read from its DER:
|
|
74
|
+
* { signatureAlgorithm: "ecdsa-with-SHA256" | … | "oid:<hex>", ecParams: "named" | "explicit" | null }
|
|
75
|
+
* (ecParams is null for a non-EC key).
|
|
76
|
+
*/
|
|
77
|
+
export function describeCert(pemOrDer) {
|
|
78
|
+
const der =
|
|
79
|
+
Buffer.isBuffer(pemOrDer) && pemOrDer[0] === 0x30
|
|
80
|
+
? pemOrDer
|
|
81
|
+
: new crypto.X509Certificate(pemOrDer).raw;
|
|
82
|
+
const [tbs, sigAlg] = children(der, tlv(der, 0)); // Certificate ::= SEQ { tbs, sigAlg, sig }
|
|
83
|
+
const sigOid = hex(der, children(der, sigAlg)[0]);
|
|
84
|
+
const t = children(der, tbs);
|
|
85
|
+
const spki = t[t[0].tag === 0xa0 ? 6 : 5]; // [0] version?, serial, sigAlg, issuer, validity, subject, spki
|
|
86
|
+
const [algId] = children(der, spki);
|
|
87
|
+
const [keyOid, params] = children(der, algId);
|
|
88
|
+
let ecParams = null;
|
|
89
|
+
if (hex(der, keyOid) === OID_EC_PUBLIC_KEY) {
|
|
90
|
+
ecParams = params && params.tag === 0x06 ? "named" : "explicit";
|
|
91
|
+
}
|
|
92
|
+
return { signatureAlgorithm: SIG_ALGS[sigOid] || `oid:${sigOid}`, ecParams };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** What makes this certificate unusable by Node's TLS stack, as phrases; empty when none. */
|
|
96
|
+
export function certProblems(pem) {
|
|
97
|
+
const { signatureAlgorithm, ecParams } = describeCert(pem);
|
|
98
|
+
const problems = [];
|
|
99
|
+
if (!STRONG_SIG_ALGS.has(signatureAlgorithm)) {
|
|
100
|
+
problems.push(`signed with ${signatureAlgorithm} (Node needs SHA-256 or stronger)`);
|
|
101
|
+
}
|
|
102
|
+
if (ecParams === "explicit") {
|
|
103
|
+
problems.push("EC key with explicit curve parameters (Node needs a named curve)");
|
|
104
|
+
}
|
|
105
|
+
return problems;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* "/usr/bin/openssl (LibreSSL 3.3.6)" — the `openssl` the proxy runs (the first on env.PATH, as
|
|
110
|
+
* execFileSync resolves it), for error messages. The version comes from running THAT binary.
|
|
111
|
+
*/
|
|
112
|
+
export function opensslIdentity(env = process.env) {
|
|
113
|
+
let found = null;
|
|
114
|
+
for (const dir of (env.PATH || "").split(path.delimiter)) {
|
|
115
|
+
if (!dir) continue; // an empty PATH segment ("a::b") is not the cwd here
|
|
116
|
+
const f = path.join(dir, "openssl");
|
|
117
|
+
try {
|
|
118
|
+
fs.accessSync(f, fs.constants.X_OK);
|
|
119
|
+
found = f;
|
|
120
|
+
break;
|
|
121
|
+
} catch {
|
|
122
|
+
/* next */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!found) return "openssl (not found on PATH)";
|
|
126
|
+
let version = "version unknown";
|
|
127
|
+
try {
|
|
128
|
+
version = execFileSync(found, ["version"], {
|
|
129
|
+
encoding: "utf8",
|
|
130
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
131
|
+
env,
|
|
132
|
+
}).trim();
|
|
133
|
+
} catch {
|
|
134
|
+
/* keep "version unknown" */
|
|
135
|
+
}
|
|
136
|
+
return `${found} (${version})`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Throws if `cert` (the CA, when `role` is "CA") cannot be used by Node. For a leaf, also builds
|
|
141
|
+
* the exact secure context https.createServer / setSecureContext will, so anything else Node
|
|
142
|
+
* refuses (a key/cert mismatch, a digest under the security level) fails here, with a message.
|
|
143
|
+
*/
|
|
144
|
+
export function assertUsableCert({ role, cert, key }, env = process.env) {
|
|
145
|
+
const problems = certProblems(cert);
|
|
146
|
+
if (!problems.length && key) {
|
|
147
|
+
try {
|
|
148
|
+
tls.createSecureContext({ key, cert });
|
|
149
|
+
} catch (e) {
|
|
150
|
+
problems.push(`Node refuses it: ${e.code || e.message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (problems.length) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`${opensslIdentity(env)} minted a ${role} certificate Node cannot use: ${problems.join("; ")}. ` +
|
|
156
|
+
"Put OpenSSL 3 first on PATH (macOS: `brew install openssl@3`) and relaunch.",
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The leaf in force, and how to replace it. rotate() mints a new leaf and installs it; only when
|
|
163
|
+
* BOTH succeed does the new leaf become current. Before this, `leaf = mint()` ran ahead of the
|
|
164
|
+
* install, so an install that threw left the server serving the old leaf while `notAfter` already
|
|
165
|
+
* held the new leaf's expiry — the CONNECT handler's "expiring" check then never retried, and the
|
|
166
|
+
* session broke with an expired certificate once the original leaf ran out.
|
|
167
|
+
*/
|
|
168
|
+
export function makeLeafRotator({ initial, mint, install, log, errText = (e) => e.message }) {
|
|
169
|
+
let leaf = initial;
|
|
170
|
+
return {
|
|
171
|
+
current: () => leaf,
|
|
172
|
+
rotate(why) {
|
|
173
|
+
let next;
|
|
174
|
+
try {
|
|
175
|
+
next = mint();
|
|
176
|
+
install(next);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
log(`leaf rotation FAILED (${why}), keeping the current leaf: ${errText(e)}`);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
leaf = next;
|
|
182
|
+
log(`leaf rotated (${why}) notAfter=${new Date(leaf.notAfter).toISOString()}`);
|
|
183
|
+
return true;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
package/dist/rc-proxy.mjs
CHANGED
|
@@ -92,6 +92,7 @@ import crypto from "node:crypto";
|
|
|
92
92
|
import { execFileSync } from "node:child_process";
|
|
93
93
|
import { TURN_HEADER, TurnOrigins, turnHeaderEnabled, turnHeaderFor } from "./turn-origin.mjs";
|
|
94
94
|
import { OWNER_FILE, ensureRcStateRoot, keepPrivateDir, sweepRcDirs } from "./rc-state.mjs";
|
|
95
|
+
import { assertUsableCert, makeLeafRotator } from "./rc-certs.mjs";
|
|
95
96
|
|
|
96
97
|
const MITM_HOST = "api.anthropic.com";
|
|
97
98
|
const env = process.env;
|
|
@@ -180,7 +181,19 @@ if (SUBSCRIPTION) {
|
|
|
180
181
|
// session picks it up on its next TLS handshake. (A 2-day CA + leaf with the CA key deleted at
|
|
181
182
|
// startup broke every session older than 48 h with "SSL certificate has expired".) openssl only
|
|
182
183
|
// takes the CA key from a file, so each signing writes it 0600 into caDir and deletes it right
|
|
183
|
-
// after.
|
|
184
|
+
// after. The openssl flags used (`-addext`, `-pkeyopt`, `-sha256`) exist in OpenSSL 1.1.1+ and 3
|
|
185
|
+
// and in LibreSSL 3.3+ (macOS's /usr/bin/openssl).
|
|
186
|
+
//
|
|
187
|
+
// Until the digest and curve encoding were pinned, this only ever worked with OpenSSL 3 first on
|
|
188
|
+
// PATH: no call passed a digest, and LibreSSL's defaults differ from OpenSSL 3's in two ways that
|
|
189
|
+
// each break Node (OpenSSL 3), whatever the Node version. (1) `x509 -req` signs with
|
|
190
|
+
// ecdsa-with-SHA1, and https.createServer refuses that leaf with ERR_SSL_CA_MD_TOO_WEAK — the proxy
|
|
191
|
+
// died before READY and --remote-control refused to launch. (2) EC keys are written with explicit
|
|
192
|
+
// curve parameters, and a client verifying the chain rejects them ("Certificate public key has
|
|
193
|
+
// explicit ECC parameters"). So every call passes `-sha256` and `ec_param_enc:named_curve`, and
|
|
194
|
+
// every minted CA and leaf is checked before use (assertUsableCert, rc-certs.mjs): an openssl that
|
|
195
|
+
// still produces something Node cannot use stops the proxy with a message naming the binary and
|
|
196
|
+
// its version — or, for a rotation, fails that rotation and keeps the current leaf.
|
|
184
197
|
//
|
|
185
198
|
// Nothing may take the CA files mid-session. In the OS temp dir, macOS's dirhelper deleted
|
|
186
199
|
// ca.pem / leaf.ext from sessions older than ~3-4 days (files not accessed for 3 days; openssl
|
|
@@ -240,6 +253,8 @@ try {
|
|
|
240
253
|
"ec",
|
|
241
254
|
"-pkeyopt",
|
|
242
255
|
"ec_paramgen_curve:prime256v1",
|
|
256
|
+
"-pkeyopt",
|
|
257
|
+
"ec_param_enc:named_curve",
|
|
243
258
|
"-nodes",
|
|
244
259
|
"-keyout",
|
|
245
260
|
p("ca.key"),
|
|
@@ -253,6 +268,7 @@ try {
|
|
|
253
268
|
"basicConstraints=critical,CA:TRUE",
|
|
254
269
|
"-addext",
|
|
255
270
|
"keyUsage=critical,keyCertSign,cRLSign",
|
|
271
|
+
"-sha256",
|
|
256
272
|
);
|
|
257
273
|
caKey = fs.readFileSync(p("ca.key"));
|
|
258
274
|
fs.rmSync(p("ca.key"));
|
|
@@ -262,6 +278,11 @@ try {
|
|
|
262
278
|
fs.rmSync(p("ca.key"), { force: true });
|
|
263
279
|
die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
|
|
264
280
|
}
|
|
281
|
+
try {
|
|
282
|
+
assertUsableCert({ role: "CA", cert: caPem });
|
|
283
|
+
} catch (e) {
|
|
284
|
+
die(`${e.message} Refusing to start.`);
|
|
285
|
+
}
|
|
265
286
|
|
|
266
287
|
// Recreate caDir and its long-lived files from memory if something deleted them, and refresh
|
|
267
288
|
// every atime/mtime so the cleaner does not try again (see the block comment above).
|
|
@@ -299,6 +320,8 @@ function mintLeaf() {
|
|
|
299
320
|
"ec",
|
|
300
321
|
"-pkeyopt",
|
|
301
322
|
"ec_paramgen_curve:prime256v1",
|
|
323
|
+
"-pkeyopt",
|
|
324
|
+
"ec_param_enc:named_curve",
|
|
302
325
|
"-nodes",
|
|
303
326
|
"-keyout",
|
|
304
327
|
p("leaf.key"),
|
|
@@ -306,6 +329,7 @@ function mintLeaf() {
|
|
|
306
329
|
p("leaf.csr"),
|
|
307
330
|
"-subj",
|
|
308
331
|
`/CN=${MITM_HOST}`,
|
|
332
|
+
"-sha256",
|
|
309
333
|
);
|
|
310
334
|
try {
|
|
311
335
|
fs.writeFileSync(p("ca.key"), caKey, { mode: 0o600, flag: "wx" });
|
|
@@ -325,23 +349,27 @@ function mintLeaf() {
|
|
|
325
349
|
String(LEAF_DAYS),
|
|
326
350
|
"-extfile",
|
|
327
351
|
p("leaf.ext"),
|
|
352
|
+
"-sha256",
|
|
328
353
|
);
|
|
329
354
|
} finally {
|
|
330
355
|
fs.rmSync(p("ca.key"), { force: true });
|
|
331
356
|
}
|
|
332
357
|
const cert = fs.readFileSync(p("leaf.pem"));
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
notAfter: Date.parse(new crypto.X509Certificate(cert).validTo),
|
|
337
|
-
};
|
|
358
|
+
const key = fs.readFileSync(p("leaf.key"));
|
|
359
|
+
assertUsableCert({ role: "leaf", cert, key }); // fail closed: never serve a leaf Node refuses
|
|
360
|
+
return { key, cert, notAfter: Date.parse(new crypto.X509Certificate(cert).validTo) };
|
|
338
361
|
}
|
|
339
362
|
|
|
340
|
-
let
|
|
363
|
+
let firstLeaf;
|
|
341
364
|
try {
|
|
342
|
-
|
|
365
|
+
firstLeaf = mintLeaf();
|
|
343
366
|
} catch (e) {
|
|
344
|
-
|
|
367
|
+
// An openssl failure carries its stderr; an unusable certificate carries assertUsableCert's message.
|
|
368
|
+
const why =
|
|
369
|
+
e.stderr && e.stderr.length
|
|
370
|
+
? `could not mint the ephemeral CA with openssl: ${osslErr(e)}`
|
|
371
|
+
: null;
|
|
372
|
+
die(why || `${e.message} Refusing to start.`);
|
|
345
373
|
}
|
|
346
374
|
|
|
347
375
|
// --- request handling ---------------------------------------------------------------------
|
|
@@ -613,22 +641,22 @@ function handle(req, res, viaPlain) {
|
|
|
613
641
|
}
|
|
614
642
|
}
|
|
615
643
|
|
|
616
|
-
const tlsServer = https.createServer({ key:
|
|
644
|
+
const tlsServer = https.createServer({ key: firstLeaf.key, cert: firstLeaf.cert }, (req, res) =>
|
|
617
645
|
handle(req, res, false),
|
|
618
646
|
);
|
|
619
647
|
tlsServer.on("tlsClientError", (e) => log(`tls client error: ${e.code || e.message}`));
|
|
620
648
|
|
|
621
649
|
// Re-mint the leaf and hot-swap it; established TLS connections keep the old one, every new
|
|
622
|
-
// handshake gets the new one. A failed re-mint keeps serving the current leaf
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
650
|
+
// handshake gets the new one. A failed re-mint OR a failed swap keeps serving the current leaf —
|
|
651
|
+
// and keeps its notAfter, so the CONNECT handler's "expiring" check retries (rc-certs.mjs).
|
|
652
|
+
const leaves = makeLeafRotator({
|
|
653
|
+
initial: firstLeaf,
|
|
654
|
+
mint: mintLeaf,
|
|
655
|
+
install: (next) => tlsServer.setSecureContext({ key: next.key, cert: next.cert }),
|
|
656
|
+
log,
|
|
657
|
+
errText: osslErr,
|
|
658
|
+
});
|
|
659
|
+
const rotateLeaf = (why) => leaves.rotate(why);
|
|
632
660
|
rotateTimer = setInterval(() => rotateLeaf("interval"), LEAF_ROTATE_MS);
|
|
633
661
|
rotateTimer.unref();
|
|
634
662
|
keepAliveTimer = setInterval(() => keepAlive("interval"), CA_KEEPALIVE_MS);
|
|
@@ -641,7 +669,7 @@ proxy.on("connect", (req, sock, head) => {
|
|
|
641
669
|
sock.on("error", () => {});
|
|
642
670
|
if (host === MITM_HOST && port === 443) {
|
|
643
671
|
// A timer can run late (laptop sleep): never hand out a leaf that is about to expire.
|
|
644
|
-
if (
|
|
672
|
+
if (leaves.current().notAfter - Date.now() < LEAF_MIN_LEFT_MS) rotateLeaf("expiring");
|
|
645
673
|
sock.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
646
674
|
if (head && head.length) sock.unshift(head);
|
|
647
675
|
tlsServer.emit("connection", sock);
|