@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.
@@ -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. Works with macOS's system LibreSSL (3.3+) and OpenSSL 1.1.1+ (`-addext`).
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
- return {
334
- key: fs.readFileSync(p("leaf.key")),
335
- cert,
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 leaf;
363
+ let firstLeaf;
341
364
  try {
342
- leaf = mintLeaf();
365
+ firstLeaf = mintLeaf();
343
366
  } catch (e) {
344
- die(`could not mint the ephemeral CA with openssl: ${osslErr(e)}`);
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: leaf.key, cert: leaf.cert }, (req, res) =>
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
- function rotateLeaf(why) {
624
- try {
625
- leaf = mintLeaf();
626
- tlsServer.setSecureContext({ key: leaf.key, cert: leaf.cert });
627
- log(`leaf rotated (${why}) notAfter=${new Date(leaf.notAfter).toISOString()}`);
628
- } catch (e) {
629
- log(`leaf rotation FAILED (${why}), keeping the current leaf: ${osslErr(e)}`);
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 (leaf.notAfter - Date.now() < LEAF_MIN_LEFT_MS) rotateLeaf("expiring");
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemom/mnemom",
3
- "version": "0.17.23",
3
+ "version": "0.17.24",
4
4
  "description": "Transparent AI agent tracing",
5
5
  "type": "module",
6
6
  "bin": {