@mnemom/mnemom 0.17.23 → 0.17.25
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/commands/agent-slack.d.ts +10 -1
- package/dist/commands/agent-slack.js +41 -2
- package/dist/rc-certs.mjs +186 -0
- package/dist/rc-proxy.mjs +49 -21
- package/package.json +1 -1
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* mnemom agent slack login # store the bot + app-level tokens (0600)
|
|
7
7
|
* mnemom agent slack logout # remove them
|
|
8
8
|
* mnemom agent slack status # tokens, bot identity, router, live sessions
|
|
9
|
-
* mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] <text…|->
|
|
9
|
+
* mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] [--] <text…|->
|
|
10
|
+
* # --help prints usage; unknown flags are refused
|
|
10
11
|
* mnemom agent slack delete [--channel C] <ts> # delete a message THIS session posted
|
|
11
12
|
* mnemom agent slack inbox [--peek] # unread messages addressed to THIS session
|
|
12
13
|
* mnemom agent slack listen # this session's channel server (spawned by --slack)
|
|
@@ -21,7 +22,15 @@ interface PostArgs {
|
|
|
21
22
|
channel?: string;
|
|
22
23
|
thread?: string;
|
|
23
24
|
iconEmoji?: string;
|
|
25
|
+
/** `--help` / `-h` was given: print POST_USAGE and post nothing. */
|
|
26
|
+
help?: true;
|
|
24
27
|
text: string;
|
|
25
28
|
}
|
|
29
|
+
export declare const POST_USAGE = "Usage: mnemom agent slack post [options] [--] <text\u2026|->\n\nPost a message to Slack as this session.\n\nOptions:\n -c, --channel <C> channel name or id (default: slack_channel from `mnemom agent config`)\n -t, --thread <TS> reply in the thread whose parent message has this ts\n --icon-emoji <E> emoji to show as the poster's icon\n -h, --help show this help and exit; nothing is posted\n\nText:\n The remaining words, joined by spaces. `-` (or no words with a piped stdin) reads stdin.\n Any other word starting with \"-\" before `--` is refused as an unknown flag. A message\n that starts with \"-\" must come after `--`, e.g. mnemom agent slack post -c C1 -- \"-1 on this\"";
|
|
30
|
+
/**
|
|
31
|
+
* Parse `post` arguments. Before a `--` separator, `--help`/`-h` asks for usage and any
|
|
32
|
+
* other dash-word that is not a known flag is refused, so a typo or a guessed flag can
|
|
33
|
+
* never be posted as message text. Everything after `--` is text, verbatim.
|
|
34
|
+
*/
|
|
26
35
|
export declare function parsePostArgs(argv: string[]): PostArgs;
|
|
27
36
|
export {};
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* mnemom agent slack login # store the bot + app-level tokens (0600)
|
|
7
7
|
* mnemom agent slack logout # remove them
|
|
8
8
|
* mnemom agent slack status # tokens, bot identity, router, live sessions
|
|
9
|
-
* mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] <text…|->
|
|
9
|
+
* mnemom agent slack post [--channel C] [--thread TS] [--icon-emoji E] [--] <text…|->
|
|
10
|
+
* # --help prints usage; unknown flags are refused
|
|
10
11
|
* mnemom agent slack delete [--channel C] <ts> # delete a message THIS session posted
|
|
11
12
|
* mnemom agent slack inbox [--peek] # unread messages addressed to THIS session
|
|
12
13
|
* mnemom agent slack listen # this session's channel server (spawned by --slack)
|
|
@@ -121,15 +122,39 @@ async function status() {
|
|
|
121
122
|
const allow = loadLauncherConfig().slackAllow ?? [];
|
|
122
123
|
out(`allowed senders: ${allow.length ? allow.join(", ") : "none — no message will reach a session"}`);
|
|
123
124
|
}
|
|
125
|
+
export const POST_USAGE = `Usage: mnemom agent slack post [options] [--] <text…|->
|
|
126
|
+
|
|
127
|
+
Post a message to Slack as this session.
|
|
128
|
+
|
|
129
|
+
Options:
|
|
130
|
+
-c, --channel <C> channel name or id (default: slack_channel from \`mnemom agent config\`)
|
|
131
|
+
-t, --thread <TS> reply in the thread whose parent message has this ts
|
|
132
|
+
--icon-emoji <E> emoji to show as the poster's icon
|
|
133
|
+
-h, --help show this help and exit; nothing is posted
|
|
134
|
+
|
|
135
|
+
Text:
|
|
136
|
+
The remaining words, joined by spaces. \`-\` (or no words with a piped stdin) reads stdin.
|
|
137
|
+
Any other word starting with "-" before \`--\` is refused as an unknown flag. A message
|
|
138
|
+
that starts with "-" must come after \`--\`, e.g. mnemom agent slack post -c C1 -- "-1 on this"`;
|
|
139
|
+
const POST_SHORT_FLAGS = new Set(["-c", "-t", "-h"]);
|
|
140
|
+
/**
|
|
141
|
+
* Parse `post` arguments. Before a `--` separator, `--help`/`-h` asks for usage and any
|
|
142
|
+
* other dash-word that is not a known flag is refused, so a typo or a guessed flag can
|
|
143
|
+
* never be posted as message text. Everything after `--` is text, verbatim.
|
|
144
|
+
*/
|
|
124
145
|
export function parsePostArgs(argv) {
|
|
125
146
|
const res = { text: "" };
|
|
126
147
|
const words = [];
|
|
148
|
+
let unknown;
|
|
127
149
|
for (let i = 0; i < argv.length; i++) {
|
|
128
150
|
const a = argv[i];
|
|
129
151
|
const val = () => {
|
|
130
152
|
const v = argv[++i];
|
|
131
|
-
|
|
153
|
+
// A flag is never a value: `--channel --help` must not eat the --help. (A short
|
|
154
|
+
// dash-word that is not a flag stays a value: `--icon-emoji -1` is Slack's thumbsdown.)
|
|
155
|
+
if (v === undefined || v.startsWith("--") || POST_SHORT_FLAGS.has(v)) {
|
|
132
156
|
throw new Error(`mnemom agent slack post: ${a} needs a value.`);
|
|
157
|
+
}
|
|
133
158
|
return v;
|
|
134
159
|
};
|
|
135
160
|
if (a === "--channel" || a === "-c")
|
|
@@ -138,18 +163,32 @@ export function parsePostArgs(argv) {
|
|
|
138
163
|
res.thread = val();
|
|
139
164
|
else if (a === "--icon-emoji")
|
|
140
165
|
res.iconEmoji = val();
|
|
166
|
+
else if (a === "--help" || a === "-h")
|
|
167
|
+
res.help = true;
|
|
141
168
|
else if (a === "--") {
|
|
142
169
|
words.push(...argv.slice(i + 1));
|
|
143
170
|
break;
|
|
144
171
|
}
|
|
172
|
+
else if (a.startsWith("-") && a !== "-")
|
|
173
|
+
unknown ??= a;
|
|
145
174
|
else
|
|
146
175
|
words.push(a);
|
|
147
176
|
}
|
|
177
|
+
// Help wins over an unknown flag: someone asking for usage should get it.
|
|
178
|
+
if (res.help)
|
|
179
|
+
return { help: true, text: "" };
|
|
180
|
+
if (unknown !== undefined) {
|
|
181
|
+
throw new Error(`mnemom agent slack post: unknown flag ${unknown}; put message text after --. See \`mnemom agent slack post --help\`.`);
|
|
182
|
+
}
|
|
148
183
|
res.text = words.join(" ");
|
|
149
184
|
return res;
|
|
150
185
|
}
|
|
151
186
|
async function post(argv) {
|
|
152
187
|
const args = parsePostArgs(argv);
|
|
188
|
+
if (args.help) {
|
|
189
|
+
process.stdout.write(POST_USAGE + "\n");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
153
192
|
let text = args.text;
|
|
154
193
|
if (text === "-" || (!text && !process.stdin.isTTY))
|
|
155
194
|
text = readFileSync(0, "utf8");
|
|
@@ -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);
|