@runuai/host 0.9.148 → 0.9.150
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/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +312 -2
- package/images/standard/Dockerfile +44 -6
- package/images/standard/container/uai-init +85 -2
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/lib/agent-cli.ts +18 -1
- package/lib/agent.ts +99 -34
- package/lib/agents/codex.ts +9 -1
- package/lib/agents/cursor.ts +15 -1
- package/lib/agents/grok.ts +8 -0
- package/lib/agents/kimi.ts +1 -0
- package/lib/agents/machine-durable.ts +2 -0
- package/lib/agents/opencode.ts +1 -0
- package/lib/agents/proc.ts +6 -1
- package/lib/agents/transport.ts +9 -5
- package/lib/browser-testing.ts +6 -0
- package/lib/codex-auth.ts +2 -0
- package/lib/credential-store.ts +141 -6
- package/lib/engine-accounts.ts +1 -0
- package/lib/env-file.ts +7 -0
- package/lib/env-schema.ts +841 -51
- package/lib/git-diff.ts +1 -0
- package/lib/git-identity.ts +2 -0
- package/lib/github-tokens.ts +1 -0
- package/lib/host-env.ts +98 -18
- package/lib/lease.ts +11 -3
- package/lib/machine-gateway-tunnel.ts +195 -17
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/managed-runtime.ts +270 -51
- package/lib/mcp-gateway.ts +777 -26
- package/lib/orchestrator.ts +408 -81
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-loader.ts +237 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/runtime-archive.ts +79 -16
- package/lib/runtime-authority.ts +44 -0
- package/lib/runtime-state.ts +175 -10
- package/lib/secrets-proxy/address.ts +72 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +60 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +141 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +574 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/shared-files-s3.ts +3 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +51 -3
- package/lib/standard-image.ts +50 -12
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +193 -0
- package/lib/task-environment/apple-container.ts +45 -11
- package/lib/task-environment/docker.ts +51 -12
- package/lib/task-environment/index.ts +6 -2
- package/lib/task-environment/machine-task-up.ts +258 -25
- package/lib/task-environment/machine.ts +80 -22
- package/lib/task-environment/registry.ts +3 -2
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +92 -16
- package/lib/task-environment/types.ts +63 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +44 -0
- package/lib/task-gateway-state.ts +497 -0
- package/lib/transcript.ts +1 -0
- package/package.json +13 -1
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/task-up.sh +476 -27
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/src/cli.ts +26 -3
- package/src/index.ts +273 -19
- package/src/main.ts +939 -48
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +281 -0
- package/src/project-env-wording.ts +47 -0
- package/src/protocol.ts +2219 -15
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner's secrets-proxy CAs, kept across restarts (ADR-153 §3 "The CA").
|
|
3
|
+
*
|
|
4
|
+
* A task installs the CA bundle each time its container or VM starts and
|
|
5
|
+
* trusts exactly that until the next start, so the signer may only ever
|
|
6
|
+
* change to a CA the tasks already trust. Rotation is therefore an overlap:
|
|
7
|
+
* - a CA lives five years and signs every leaf until its last day;
|
|
8
|
+
* - inside its last 90 days a successor is minted, and every task started
|
|
9
|
+
* from then on installs both (`trusted`);
|
|
10
|
+
* - on its last day it retires and the successor signs.
|
|
11
|
+
* A task last started before its successor existed — one running for more
|
|
12
|
+
* than 89 days across the switch — trusts only the retiring CA and must be
|
|
13
|
+
* restarted. That is logged loudly when the successor is minted and again at
|
|
14
|
+
* the switch; the runner also re-installs the bundle into running tasks when
|
|
15
|
+
* it changes, which covers every process started after that.
|
|
16
|
+
*
|
|
17
|
+
* The certificates are stored in the clear (every task gets a copy anyway);
|
|
18
|
+
* the private keys are sealed with host-master.key and never leave this
|
|
19
|
+
* process in any other form.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { writeDurableFileAtomic } from "../env-file";
|
|
26
|
+
import { createCertificateAuthority, loadCertificateAuthority, type CertificateAuthority } from "./ca";
|
|
27
|
+
|
|
28
|
+
const STORE_VERSION = 1;
|
|
29
|
+
const DAY_MS = 24 * 60 * 60 * 1_000;
|
|
30
|
+
const CA_VALIDITY_DAYS = 5 * 365;
|
|
31
|
+
/** Inside its last 90 days a CA gets a successor, installed alongside it. */
|
|
32
|
+
export const CA_SUCCESSOR_WITHIN_MS = 90 * DAY_MS;
|
|
33
|
+
/** On its last day a CA stops signing and its successor takes over. */
|
|
34
|
+
export const CA_RETIRE_WITHIN_MS = DAY_MS;
|
|
35
|
+
/** Signer and successor: the windows above never leave more alive at once. */
|
|
36
|
+
const MAX_AUTHORITIES = 2;
|
|
37
|
+
|
|
38
|
+
export interface SealedText {
|
|
39
|
+
ct: Buffer;
|
|
40
|
+
nonce: Buffer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CaStoreDeps {
|
|
44
|
+
/** Directory for `ca.json` (created 0700). */
|
|
45
|
+
dir: string;
|
|
46
|
+
seal(plaintext: string): SealedText;
|
|
47
|
+
open(ct: Buffer, nonce: Buffer): string;
|
|
48
|
+
now?(): number;
|
|
49
|
+
log?: Pick<Console, "warn">;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CertificateAuthorities {
|
|
53
|
+
/** Signs every leaf: the oldest CA not yet retired. */
|
|
54
|
+
readonly signer: CertificateAuthority;
|
|
55
|
+
/** What a starting task installs: the signer and, inside its last 90 days,
|
|
56
|
+
* its successor. */
|
|
57
|
+
readonly trusted: readonly CertificateAuthority[];
|
|
58
|
+
/** When this set next changes: a successor is due, or the signer retires. */
|
|
59
|
+
readonly reviewAt: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface StoredAuthority {
|
|
63
|
+
commonName: string;
|
|
64
|
+
certificatePem: string;
|
|
65
|
+
key: { ct: string; nonce: string };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface StoredCa {
|
|
69
|
+
v: number;
|
|
70
|
+
authorities: StoredAuthority[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isStoredAuthority(value: unknown): value is StoredAuthority {
|
|
74
|
+
if (value === null || typeof value !== "object") return false;
|
|
75
|
+
const entry = value as Record<string, unknown>;
|
|
76
|
+
const key = entry.key as Record<string, unknown> | undefined;
|
|
77
|
+
return (
|
|
78
|
+
typeof entry.commonName === "string" &&
|
|
79
|
+
typeof entry.certificatePem === "string" &&
|
|
80
|
+
key !== undefined &&
|
|
81
|
+
key !== null &&
|
|
82
|
+
typeof key.ct === "string" &&
|
|
83
|
+
typeof key.nonce === "string"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isStoredCa(value: unknown): value is StoredCa {
|
|
88
|
+
if (value === null || typeof value !== "object") return false;
|
|
89
|
+
const stored = value as Record<string, unknown>;
|
|
90
|
+
return (
|
|
91
|
+
stored.v === STORE_VERSION &&
|
|
92
|
+
Array.isArray(stored.authorities) &&
|
|
93
|
+
stored.authorities.length >= 1 &&
|
|
94
|
+
stored.authorities.length <= MAX_AUTHORITIES &&
|
|
95
|
+
stored.authorities.every(isStoredAuthority)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const dateOf = (epochMs: number): string => new Date(epochMs).toISOString().slice(0, 10);
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Returns the runner's CAs, minting the first one, a successor or (after a
|
|
103
|
+
* runner was down through a whole overlap) a replacement as the dates call
|
|
104
|
+
* for, and persisting any change. Throws when a stored CA exists but cannot
|
|
105
|
+
* be opened or fails validation: that is a broken store, not a reason to
|
|
106
|
+
* mint a new CA behind every running task's back.
|
|
107
|
+
*/
|
|
108
|
+
export function loadOrCreateCertificateAuthorities(deps: CaStoreDeps): CertificateAuthorities {
|
|
109
|
+
const now = deps.now ?? Date.now;
|
|
110
|
+
const log = deps.log ?? console;
|
|
111
|
+
mkdirSync(deps.dir, { recursive: true, mode: 0o700 });
|
|
112
|
+
const path = join(deps.dir, "ca.json");
|
|
113
|
+
const at = now();
|
|
114
|
+
|
|
115
|
+
let stored: { authority: CertificateAuthority; record: StoredAuthority }[] = [];
|
|
116
|
+
if (existsSync(path)) {
|
|
117
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
118
|
+
if (!isStoredCa(parsed)) throw new Error("the secrets-proxy CA store is not in a format this runner reads");
|
|
119
|
+
stored = parsed.authorities.map((record) => ({
|
|
120
|
+
authority: loadCertificateAuthority({
|
|
121
|
+
certificatePem: record.certificatePem,
|
|
122
|
+
privateKeyPem: deps.open(Buffer.from(record.key.ct, "base64"), Buffer.from(record.key.nonce, "base64")),
|
|
123
|
+
commonName: record.commonName,
|
|
124
|
+
}),
|
|
125
|
+
record,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const mint = (): { authority: CertificateAuthority; record: StoredAuthority } => {
|
|
130
|
+
// Dated, so the trust store tells a signer and its successor apart.
|
|
131
|
+
const commonName = `Uai secrets proxy ${dateOf(at)}`;
|
|
132
|
+
let privateKeyPem = "";
|
|
133
|
+
const authority = createCertificateAuthority({
|
|
134
|
+
now: at,
|
|
135
|
+
commonName,
|
|
136
|
+
validityDays: CA_VALIDITY_DAYS,
|
|
137
|
+
onPrivateKey: (privateKey) => {
|
|
138
|
+
privateKeyPem = String(privateKey.export({ type: "pkcs8", format: "pem" }));
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
const sealed = deps.seal(privateKeyPem);
|
|
142
|
+
return {
|
|
143
|
+
authority,
|
|
144
|
+
record: {
|
|
145
|
+
commonName,
|
|
146
|
+
certificatePem: authority.certificatePem,
|
|
147
|
+
key: { ct: sealed.ct.toString("base64"), nonce: sealed.nonce.toString("base64") },
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const live = stored
|
|
153
|
+
.filter(({ authority }) => at < authority.notAfter - CA_RETIRE_WITHIN_MS)
|
|
154
|
+
.sort((a, b) => a.authority.notAfter - b.authority.notAfter);
|
|
155
|
+
let changed = live.length !== stored.length;
|
|
156
|
+
let newest = live[live.length - 1];
|
|
157
|
+
if (newest === undefined) {
|
|
158
|
+
if (stored.length > 0) {
|
|
159
|
+
log.warn(
|
|
160
|
+
"[secrets-proxy] the proxy CA reached its last day with no successor (the runner was not running through " +
|
|
161
|
+
"the overlap); a new CA was created, and every task started before now must be restarted to trust it",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
newest = mint();
|
|
165
|
+
live.push(newest);
|
|
166
|
+
changed = true;
|
|
167
|
+
} else if (changed) {
|
|
168
|
+
// At most two are stored, so a retirement leaves the successor alone.
|
|
169
|
+
log.warn(
|
|
170
|
+
`[secrets-proxy] the proxy CA retired and its successor now signs; tasks last started before ` +
|
|
171
|
+
`${dateOf(newest.authority.notBefore)} trust only the retired CA and must be restarted`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (at >= newest.authority.notAfter - CA_SUCCESSOR_WITHIN_MS) {
|
|
175
|
+
const ending = newest.authority.notAfter;
|
|
176
|
+
live.push(mint());
|
|
177
|
+
changed = true;
|
|
178
|
+
log.warn(
|
|
179
|
+
`[secrets-proxy] the proxy CA expires on ${dateOf(ending)}; a successor was created and tasks started ` +
|
|
180
|
+
`from now trust both. It takes over on ${dateOf(ending - CA_RETIRE_WITHIN_MS)}: tasks started before ` +
|
|
181
|
+
"now must be restarted by then",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
if (changed) {
|
|
185
|
+
const next: StoredCa = { v: STORE_VERSION, authorities: live.map(({ record }) => record) };
|
|
186
|
+
// Durable: a CA lost to a power cut after tasks installed it would leave
|
|
187
|
+
// every one of them distrusting the next.
|
|
188
|
+
writeDurableFileAtomic(path, `${JSON.stringify(next)}\n`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const trusted = live.map(({ authority }) => authority);
|
|
192
|
+
const signer = trusted[0];
|
|
193
|
+
const last = trusted[trusted.length - 1];
|
|
194
|
+
if (signer === undefined || last === undefined) throw new Error("no live secrets-proxy CA");
|
|
195
|
+
return Object.freeze({
|
|
196
|
+
signer,
|
|
197
|
+
trusted: Object.freeze(trusted),
|
|
198
|
+
reviewAt: Math.min(signer.notAfter - CA_RETIRE_WITHIN_MS, last.notAfter - CA_SUCCESSOR_WITHIN_MS),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The secrets proxy's certificate authority (ADR-153 §3 "The CA").
|
|
3
|
+
*
|
|
4
|
+
* One ECDSA P-256 CA per runner signs short-lived per-domain leaves that the
|
|
5
|
+
* proxy presents to a task on the domains it declares. Tasks trust this CA;
|
|
6
|
+
* the proxy's own upstream verification uses public roots, which can never
|
|
7
|
+
* include it.
|
|
8
|
+
*
|
|
9
|
+
* Two rules keep a bug here from turning into a leak:
|
|
10
|
+
* - The CA private key is a KeyObject closed over by a signing callback. The
|
|
11
|
+
* certificate builder hands it finished TBS bytes and embeds the signature
|
|
12
|
+
* it gets back; it never receives key material.
|
|
13
|
+
* - Every certificate is parsed back with node:crypto's X509Certificate and
|
|
14
|
+
* checked straight after minting (CA-ness, SAN, EKU, issuer, signature,
|
|
15
|
+
* key match, validity, serial). Any failure throws, and callers treat a
|
|
16
|
+
* throw as "withhold": no handshake is ever answered with a certificate we
|
|
17
|
+
* could not prove correct.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
X509Certificate,
|
|
22
|
+
createHash,
|
|
23
|
+
createPrivateKey,
|
|
24
|
+
generateKeyPairSync,
|
|
25
|
+
randomBytes,
|
|
26
|
+
sign,
|
|
27
|
+
type KeyObject,
|
|
28
|
+
} from "node:crypto";
|
|
29
|
+
|
|
30
|
+
import * as der from "./der";
|
|
31
|
+
import { canonicalHostname } from "./hostname";
|
|
32
|
+
|
|
33
|
+
const OID = {
|
|
34
|
+
ecdsaWithSha256: "1.2.840.10045.4.3.2",
|
|
35
|
+
commonName: "2.5.4.3",
|
|
36
|
+
subjectKeyIdentifier: "2.5.29.14",
|
|
37
|
+
keyUsage: "2.5.29.15",
|
|
38
|
+
subjectAltName: "2.5.29.17",
|
|
39
|
+
basicConstraints: "2.5.29.19",
|
|
40
|
+
authorityKeyIdentifier: "2.5.29.35",
|
|
41
|
+
extKeyUsage: "2.5.29.37",
|
|
42
|
+
serverAuth: "1.3.6.1.5.5.7.3.1",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
/** KeyUsage named bits (RFC 5280 §4.2.1.3). */
|
|
46
|
+
const KEY_USAGE = { digitalSignature: 0, keyCertSign: 5, cRLSign: 6 } as const;
|
|
47
|
+
|
|
48
|
+
const MINUTE_MS = 60_000;
|
|
49
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
50
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
51
|
+
/** Certificates start five minutes early so a task whose clock runs a little
|
|
52
|
+
* behind the runner's (a VM after resume) still accepts a fresh leaf. */
|
|
53
|
+
const BACKDATE_MS = 5 * MINUTE_MS;
|
|
54
|
+
const MAX_CA_VALIDITY_DAYS = 3_650;
|
|
55
|
+
/** The ADR's leaves live 24 h; this is only the ceiling a caller may ask for. */
|
|
56
|
+
const MAX_LEAF_VALIDITY_HOURS = 7 * 24;
|
|
57
|
+
const DEFAULT_LEAF_VALIDITY_HOURS = 24;
|
|
58
|
+
/** Re-mint an hour before expiry, so a leaf is never served in its last
|
|
59
|
+
* hour and a long-lived connection never straddles its notAfter — or a
|
|
60
|
+
* quarter of the leaf's life when that is shorter (a short validity, or a
|
|
61
|
+
* leaf capped by a CA near its own end), so a cached leaf always has a
|
|
62
|
+
* usable window rather than being re-minted on every handshake. */
|
|
63
|
+
const LEAF_RENEW_BEFORE_MS = HOUR_MS;
|
|
64
|
+
const DEFAULT_LEAF_CACHE_ENTRIES = 256;
|
|
65
|
+
|
|
66
|
+
/** Signs a finished TBSCertificate; the only door to a CA key. */
|
|
67
|
+
export type SignTbs = (tbs: Buffer) => Buffer;
|
|
68
|
+
|
|
69
|
+
export interface CertificateAuthority {
|
|
70
|
+
readonly certificatePem: string;
|
|
71
|
+
readonly certificate: X509Certificate;
|
|
72
|
+
/** The encoded subject Name, reused byte for byte as every leaf's issuer so
|
|
73
|
+
* name matching never depends on a re-encoding. */
|
|
74
|
+
readonly subjectName: Buffer;
|
|
75
|
+
readonly keyIdentifier: Buffer;
|
|
76
|
+
readonly notBefore: number;
|
|
77
|
+
readonly notAfter: number;
|
|
78
|
+
readonly sign: SignTbs;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface LeafCertificate {
|
|
82
|
+
readonly hostname: string;
|
|
83
|
+
readonly certificatePem: string;
|
|
84
|
+
/** PKCS#8 PEM, because tls.createSecureContext does not accept a KeyObject. */
|
|
85
|
+
readonly privateKeyPem: string;
|
|
86
|
+
readonly certificate: X509Certificate;
|
|
87
|
+
readonly notBefore: number;
|
|
88
|
+
readonly notAfter: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface CertificateFields {
|
|
92
|
+
serial: Buffer;
|
|
93
|
+
issuer: Buffer;
|
|
94
|
+
subject: Buffer;
|
|
95
|
+
notBefore: number;
|
|
96
|
+
notAfter: number;
|
|
97
|
+
publicKey: KeyObject;
|
|
98
|
+
extensions: readonly Buffer[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function floorToSecond(ms: number): number {
|
|
102
|
+
return Math.floor(ms / 1_000) * 1_000;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 16 random octets with the top bit cleared: positive without a sign-guard
|
|
106
|
+
* octet, under RFC 5280's 20-octet cap, and never zero. */
|
|
107
|
+
function randomSerial(): Buffer {
|
|
108
|
+
for (;;) {
|
|
109
|
+
const serial = randomBytes(16);
|
|
110
|
+
serial[0] = (serial[0] ?? 0) & 0x7f;
|
|
111
|
+
if (serial.some((octet) => octet !== 0)) return serial;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** X.520's ub-common-name: strict X.509 stacks refuse a longer CN. */
|
|
116
|
+
const MAX_COMMON_NAME = 64;
|
|
117
|
+
|
|
118
|
+
function distinguishedName(commonName: string): Buffer {
|
|
119
|
+
return der.sequence(
|
|
120
|
+
der.set(der.sequence(der.objectIdentifier(OID.commonName), der.utf8String(commonName))),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A leaf's subject: CN = hostname when it fits X.520's bound, otherwise an
|
|
125
|
+
* empty Name — RFC 5280 §4.2.1.6 then requires a critical SAN, which is
|
|
126
|
+
* where every client looks for the name anyway. */
|
|
127
|
+
function leafSubject(hostname: string): { subject: Buffer; sanCritical: boolean } {
|
|
128
|
+
return hostname.length <= MAX_COMMON_NAME
|
|
129
|
+
? { subject: distinguishedName(hostname), sanCritical: false }
|
|
130
|
+
: { subject: der.sequence(), sanCritical: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** RFC 7093 §2 method 1: the leftmost 160 bits of SHA-256 over the
|
|
134
|
+
* subjectPublicKey bits (the uncompressed EC point). */
|
|
135
|
+
function subjectKeyIdentifier(publicKey: KeyObject): Buffer {
|
|
136
|
+
const jwk = publicKey.export({ format: "jwk" });
|
|
137
|
+
if (typeof jwk.x !== "string" || typeof jwk.y !== "string") {
|
|
138
|
+
throw new Error("expected an EC public key");
|
|
139
|
+
}
|
|
140
|
+
const point = Buffer.concat([
|
|
141
|
+
Buffer.from([0x04]),
|
|
142
|
+
Buffer.from(jwk.x, "base64url"),
|
|
143
|
+
Buffer.from(jwk.y, "base64url"),
|
|
144
|
+
]);
|
|
145
|
+
return createHash("sha256").update(point).digest().subarray(0, 20);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** DER omits `critical` when it is the DEFAULT FALSE. */
|
|
149
|
+
function extension(oid: string, critical: boolean, value: Buffer): Buffer {
|
|
150
|
+
return der.sequence(
|
|
151
|
+
der.objectIdentifier(oid),
|
|
152
|
+
...(critical ? [der.boolean(true)] : []),
|
|
153
|
+
der.octetString(value),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildCertificate(fields: CertificateFields, signTbs: SignTbs): Buffer {
|
|
158
|
+
// ecdsa-with-SHA256 takes no parameters, not even NULL (RFC 5758 §3.2).
|
|
159
|
+
const algorithm = der.sequence(der.objectIdentifier(OID.ecdsaWithSha256));
|
|
160
|
+
const tbs = der.sequence(
|
|
161
|
+
der.explicit(0, der.integer(2)), // v3
|
|
162
|
+
der.integer(fields.serial),
|
|
163
|
+
algorithm,
|
|
164
|
+
fields.issuer,
|
|
165
|
+
der.sequence(der.time(new Date(fields.notBefore)), der.time(new Date(fields.notAfter))),
|
|
166
|
+
fields.subject,
|
|
167
|
+
fields.publicKey.export({ type: "spki", format: "der" }),
|
|
168
|
+
der.explicit(3, der.sequence(...fields.extensions)),
|
|
169
|
+
);
|
|
170
|
+
return der.sequence(tbs, algorithm, der.bitString(signTbs(tbs)));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function validityOf(certificate: X509Certificate): { notBefore: number; notAfter: number } {
|
|
174
|
+
// validFromDate/validToDate only exist from Node 22.10; the string forms
|
|
175
|
+
// name the same instants on every supported runtime.
|
|
176
|
+
const from = certificate.validFromDate ?? new Date(certificate.validFrom);
|
|
177
|
+
const to = certificate.validToDate ?? new Date(certificate.validTo);
|
|
178
|
+
return { notBefore: from.getTime(), notAfter: to.getTime() };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function checkValidity(
|
|
182
|
+
failures: string[],
|
|
183
|
+
certificate: X509Certificate,
|
|
184
|
+
expected: { notBefore: number; notAfter: number },
|
|
185
|
+
maxSpanMs: number,
|
|
186
|
+
): void {
|
|
187
|
+
const { notBefore, notAfter } = validityOf(certificate);
|
|
188
|
+
if (notBefore !== expected.notBefore || notAfter !== expected.notAfter) {
|
|
189
|
+
failures.push("validity is not the requested window");
|
|
190
|
+
}
|
|
191
|
+
if (!(notAfter > notBefore) || notAfter - notBefore > maxSpanMs) {
|
|
192
|
+
failures.push("validity is not bounded");
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function checkSerial(failures: string[], certificate: X509Certificate): void {
|
|
197
|
+
const serial = certificate.serialNumber;
|
|
198
|
+
if (!/^[0-9A-Fa-f]+$/.test(serial) || BigInt(`0x${serial}`) <= 0n) {
|
|
199
|
+
failures.push("serial is not a positive integer");
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function extendedKeyUsage(certificate: X509Certificate): readonly string[] {
|
|
204
|
+
// Node names the EKU list `keyUsage` (newer releases add `extKeyUsage`);
|
|
205
|
+
// it is undefined when the extension is absent.
|
|
206
|
+
const renamed = (certificate as { extKeyUsage?: string[] }).extKeyUsage;
|
|
207
|
+
return renamed ?? certificate.keyUsage ?? [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function fail(what: string, failures: readonly string[]): void {
|
|
211
|
+
if (failures.length > 0) {
|
|
212
|
+
throw new Error(`${what} failed self-validation: ${failures.join("; ")}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Throws unless `certificate` is a self-signed CA certificate for
|
|
218
|
+
* `privateKey` over exactly the expected window. Exported for tests.
|
|
219
|
+
*/
|
|
220
|
+
export function validateCertificateAuthority(
|
|
221
|
+
certificate: X509Certificate,
|
|
222
|
+
expected: { privateKey: KeyObject; notBefore: number; notAfter: number },
|
|
223
|
+
): void {
|
|
224
|
+
const failures: string[] = [];
|
|
225
|
+
// `ca` is X509_check_ca() == 1: basicConstraints CA:TRUE, and a keyUsage
|
|
226
|
+
// that (when present) allows keyCertSign.
|
|
227
|
+
if (certificate.ca !== true) failures.push("not a CA certificate");
|
|
228
|
+
// X509_check_issued: names and key ids chain, and the issuer's keyUsage
|
|
229
|
+
// allows certificate signing — here, of itself.
|
|
230
|
+
if (!certificate.checkIssued(certificate)) failures.push("not self-issued");
|
|
231
|
+
if (!certificate.verify(certificate.publicKey)) failures.push("self-signature does not verify");
|
|
232
|
+
if (!certificate.checkPrivateKey(expected.privateKey)) failures.push("private key does not match");
|
|
233
|
+
checkValidity(failures, certificate, expected, MAX_CA_VALIDITY_DAYS * DAY_MS + BACKDATE_MS);
|
|
234
|
+
checkSerial(failures, certificate);
|
|
235
|
+
fail("certificate authority", failures);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Throws unless `certificate` is a server leaf for exactly `hostname`, issued
|
|
240
|
+
* and signed by `authority`, matching `privateKey`, over exactly the expected
|
|
241
|
+
* window. Exported for tests.
|
|
242
|
+
*/
|
|
243
|
+
export function validateLeafCertificate(
|
|
244
|
+
certificate: X509Certificate,
|
|
245
|
+
expected: {
|
|
246
|
+
authority: CertificateAuthority;
|
|
247
|
+
hostname: string;
|
|
248
|
+
privateKey: KeyObject;
|
|
249
|
+
notBefore: number;
|
|
250
|
+
notAfter: number;
|
|
251
|
+
},
|
|
252
|
+
): void {
|
|
253
|
+
const failures: string[] = [];
|
|
254
|
+
if (certificate.ca !== false) failures.push("leaf is a CA certificate");
|
|
255
|
+
if (certificate.subjectAltName !== `DNS:${expected.hostname}`) {
|
|
256
|
+
failures.push("subjectAltName is not exactly the hostname");
|
|
257
|
+
}
|
|
258
|
+
if (!extendedKeyUsage(certificate).includes(OID.serverAuth)) {
|
|
259
|
+
failures.push("extKeyUsage lacks serverAuth");
|
|
260
|
+
}
|
|
261
|
+
if (!certificate.checkIssued(expected.authority.certificate)) {
|
|
262
|
+
failures.push("not issued by the CA");
|
|
263
|
+
}
|
|
264
|
+
if (!certificate.verify(expected.authority.certificate.publicKey)) {
|
|
265
|
+
failures.push("signature does not verify against the CA");
|
|
266
|
+
}
|
|
267
|
+
if (!certificate.checkPrivateKey(expected.privateKey)) failures.push("private key does not match");
|
|
268
|
+
const matched = certificate.checkHost(expected.hostname, {
|
|
269
|
+
subject: "never",
|
|
270
|
+
wildcards: false,
|
|
271
|
+
partialWildcards: false,
|
|
272
|
+
});
|
|
273
|
+
if (matched !== expected.hostname) failures.push("hostname verification fails");
|
|
274
|
+
checkValidity(failures, certificate, expected, MAX_LEAF_VALIDITY_HOURS * HOUR_MS + BACKDATE_MS);
|
|
275
|
+
checkSerial(failures, certificate);
|
|
276
|
+
fail("leaf certificate", failures);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function createCertificateAuthority(options: {
|
|
280
|
+
now: number;
|
|
281
|
+
commonName: string;
|
|
282
|
+
validityDays: number;
|
|
283
|
+
/** Receives the new key once, for a store to seal; nothing else sees it. */
|
|
284
|
+
onPrivateKey?: (privateKey: KeyObject) => unknown;
|
|
285
|
+
}): CertificateAuthority {
|
|
286
|
+
const { now, commonName, validityDays } = options;
|
|
287
|
+
if (!Number.isFinite(now)) throw new RangeError("now must be a finite epoch-ms value");
|
|
288
|
+
// X.520 caps commonName at 64 characters.
|
|
289
|
+
if (typeof commonName !== "string" || commonName.length < 1 || commonName.length > 64) {
|
|
290
|
+
throw new RangeError("CA common name must be 1..64 characters");
|
|
291
|
+
}
|
|
292
|
+
if (!Number.isInteger(validityDays) || validityDays < 1 || validityDays > MAX_CA_VALIDITY_DAYS) {
|
|
293
|
+
throw new RangeError(`CA validity must be 1..${MAX_CA_VALIDITY_DAYS} days`);
|
|
294
|
+
}
|
|
295
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
296
|
+
const signTbs: SignTbs = (tbs) => sign("sha256", tbs, { key: privateKey, dsaEncoding: "der" });
|
|
297
|
+
const notBefore = floorToSecond(now - BACKDATE_MS);
|
|
298
|
+
const notAfter = floorToSecond(now + validityDays * DAY_MS);
|
|
299
|
+
const subjectName = distinguishedName(commonName);
|
|
300
|
+
const keyIdentifier = subjectKeyIdentifier(publicKey);
|
|
301
|
+
const certificate = new X509Certificate(
|
|
302
|
+
buildCertificate(
|
|
303
|
+
{
|
|
304
|
+
serial: randomSerial(),
|
|
305
|
+
issuer: subjectName,
|
|
306
|
+
subject: subjectName,
|
|
307
|
+
notBefore,
|
|
308
|
+
notAfter,
|
|
309
|
+
publicKey,
|
|
310
|
+
extensions: [
|
|
311
|
+
extension(OID.basicConstraints, true, der.sequence(der.boolean(true), der.integer(0))),
|
|
312
|
+
extension(
|
|
313
|
+
OID.keyUsage,
|
|
314
|
+
true,
|
|
315
|
+
der.namedBitString([KEY_USAGE.keyCertSign, KEY_USAGE.cRLSign]),
|
|
316
|
+
),
|
|
317
|
+
extension(OID.subjectKeyIdentifier, false, der.octetString(keyIdentifier)),
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
signTbs,
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
validateCertificateAuthority(certificate, { privateKey, notBefore, notAfter });
|
|
324
|
+
options.onPrivateKey?.(privateKey);
|
|
325
|
+
return Object.freeze({
|
|
326
|
+
certificatePem: certificate.toString(),
|
|
327
|
+
certificate,
|
|
328
|
+
subjectName,
|
|
329
|
+
keyIdentifier,
|
|
330
|
+
notBefore,
|
|
331
|
+
notAfter,
|
|
332
|
+
sign: signTbs,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Rebuilds a CA handle from what a store kept: the certificate, its PKCS#8
|
|
338
|
+
* private key and the common name it was created with. Everything is
|
|
339
|
+
* re-validated exactly as at creation (CA-ness, self-signature, key match,
|
|
340
|
+
* bounded validity, serial), and the subject must be the one this code
|
|
341
|
+
* encodes for that name, because every leaf embeds it byte for byte as its
|
|
342
|
+
* issuer. Throws (withhold) on any mismatch.
|
|
343
|
+
*/
|
|
344
|
+
export function loadCertificateAuthority(stored: {
|
|
345
|
+
certificatePem: string;
|
|
346
|
+
privateKeyPem: string;
|
|
347
|
+
commonName: string;
|
|
348
|
+
}): CertificateAuthority {
|
|
349
|
+
const certificate = new X509Certificate(stored.certificatePem);
|
|
350
|
+
const privateKey = createPrivateKey(stored.privateKeyPem);
|
|
351
|
+
const { notBefore, notAfter } = validityOf(certificate);
|
|
352
|
+
validateCertificateAuthority(certificate, { privateKey, notBefore, notAfter });
|
|
353
|
+
if (certificate.subject !== `CN=${stored.commonName}`) {
|
|
354
|
+
throw new Error("stored certificate authority does not carry its recorded name");
|
|
355
|
+
}
|
|
356
|
+
const signTbs: SignTbs = (tbs) => sign("sha256", tbs, { key: privateKey, dsaEncoding: "der" });
|
|
357
|
+
return Object.freeze({
|
|
358
|
+
certificatePem: certificate.toString(),
|
|
359
|
+
certificate,
|
|
360
|
+
subjectName: distinguishedName(stored.commonName),
|
|
361
|
+
keyIdentifier: subjectKeyIdentifier(certificate.publicKey),
|
|
362
|
+
notBefore,
|
|
363
|
+
notAfter,
|
|
364
|
+
sign: signTbs,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function mintLeafCertificate(options: {
|
|
369
|
+
ca: CertificateAuthority;
|
|
370
|
+
hostname: string;
|
|
371
|
+
now: number;
|
|
372
|
+
validityHours?: number;
|
|
373
|
+
}): LeafCertificate {
|
|
374
|
+
const { ca, hostname, now, validityHours = DEFAULT_LEAF_VALIDITY_HOURS } = options;
|
|
375
|
+
if (canonicalHostname(hostname) !== hostname) {
|
|
376
|
+
throw new Error("leaf hostname must be a canonical DNS name");
|
|
377
|
+
}
|
|
378
|
+
if (!Number.isFinite(now)) throw new RangeError("now must be a finite epoch-ms value");
|
|
379
|
+
if (!(validityHours > 0) || validityHours > MAX_LEAF_VALIDITY_HOURS) {
|
|
380
|
+
throw new RangeError(`leaf validity must be within (0, ${MAX_LEAF_VALIDITY_HOURS}] hours`);
|
|
381
|
+
}
|
|
382
|
+
const notBefore = floorToSecond(now - BACKDATE_MS);
|
|
383
|
+
// Past the CA's notAfter the chain fails anyway; a leaf never claims more.
|
|
384
|
+
const notAfter = Math.min(floorToSecond(now + validityHours * HOUR_MS), ca.notAfter);
|
|
385
|
+
if (now < ca.notBefore || notAfter <= now) {
|
|
386
|
+
throw new Error("certificate authority is outside its validity window");
|
|
387
|
+
}
|
|
388
|
+
const { privateKey, publicKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
389
|
+
const { subject, sanCritical } = leafSubject(hostname);
|
|
390
|
+
const certificate = new X509Certificate(
|
|
391
|
+
buildCertificate(
|
|
392
|
+
{
|
|
393
|
+
serial: randomSerial(),
|
|
394
|
+
issuer: ca.subjectName,
|
|
395
|
+
subject,
|
|
396
|
+
notBefore,
|
|
397
|
+
notAfter,
|
|
398
|
+
publicKey,
|
|
399
|
+
extensions: [
|
|
400
|
+
// CA:FALSE is the DEFAULT, so DER encodes it as an empty SEQUENCE.
|
|
401
|
+
extension(OID.basicConstraints, true, der.sequence()),
|
|
402
|
+
extension(OID.keyUsage, true, der.namedBitString([KEY_USAGE.digitalSignature])),
|
|
403
|
+
extension(OID.extKeyUsage, false, der.sequence(der.objectIdentifier(OID.serverAuth))),
|
|
404
|
+
extension(
|
|
405
|
+
OID.subjectAltName,
|
|
406
|
+
sanCritical,
|
|
407
|
+
der.sequence(der.implicit(2, der.ia5String(hostname))),
|
|
408
|
+
),
|
|
409
|
+
extension(OID.subjectKeyIdentifier, false, der.octetString(subjectKeyIdentifier(publicKey))),
|
|
410
|
+
extension(
|
|
411
|
+
OID.authorityKeyIdentifier,
|
|
412
|
+
false,
|
|
413
|
+
der.sequence(der.implicit(0, der.octetString(ca.keyIdentifier))),
|
|
414
|
+
),
|
|
415
|
+
],
|
|
416
|
+
},
|
|
417
|
+
ca.sign,
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
validateLeafCertificate(certificate, { authority: ca, hostname, privateKey, notBefore, notAfter });
|
|
421
|
+
return Object.freeze({
|
|
422
|
+
hostname,
|
|
423
|
+
certificatePem: certificate.toString(),
|
|
424
|
+
privateKeyPem: String(privateKey.export({ type: "pkcs8", format: "pem" })),
|
|
425
|
+
certificate,
|
|
426
|
+
notBefore,
|
|
427
|
+
notAfter,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The CA handle the proxy engine takes: leaves minted on demand, cached per
|
|
432
|
+
* hostname, and re-minted an hour before they expire. */
|
|
433
|
+
export interface LeafCertificateCache {
|
|
434
|
+
readonly authority: CertificateAuthority;
|
|
435
|
+
/** A valid leaf for a canonical hostname. Throws (withhold) when minting or
|
|
436
|
+
* self-validation fails. */
|
|
437
|
+
leafFor(hostname: string): LeafCertificate;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export function createLeafCertificateCache(options: {
|
|
441
|
+
ca: CertificateAuthority;
|
|
442
|
+
now?: () => number;
|
|
443
|
+
validityHours?: number;
|
|
444
|
+
maxEntries?: number;
|
|
445
|
+
}): LeafCertificateCache {
|
|
446
|
+
const { ca, now = Date.now, validityHours = DEFAULT_LEAF_VALIDITY_HOURS } = options;
|
|
447
|
+
const maxEntries = Math.max(1, Math.floor(options.maxEntries ?? DEFAULT_LEAF_CACHE_ENTRIES));
|
|
448
|
+
const leaves = new Map<string, LeafCertificate>();
|
|
449
|
+
return {
|
|
450
|
+
authority: ca,
|
|
451
|
+
leafFor(hostname: string): LeafCertificate {
|
|
452
|
+
const at = now();
|
|
453
|
+
const cached = leaves.get(hostname);
|
|
454
|
+
if (cached !== undefined) {
|
|
455
|
+
const renewBefore = Math.min(LEAF_RENEW_BEFORE_MS, (cached.notAfter - cached.notBefore) / 4);
|
|
456
|
+
if (at >= cached.notBefore && at < cached.notAfter - renewBefore) return cached;
|
|
457
|
+
}
|
|
458
|
+
leaves.delete(hostname);
|
|
459
|
+
const leaf = mintLeafCertificate({ ca, hostname, now: at, validityHours });
|
|
460
|
+
// Only declared domains reach here (the SNI check runs first), so the
|
|
461
|
+
// cap is a backstop against a pathological declaration, not an LRU.
|
|
462
|
+
if (leaves.size >= maxEntries) {
|
|
463
|
+
const oldest = leaves.keys().next();
|
|
464
|
+
if (oldest.done !== true) leaves.delete(oldest.value);
|
|
465
|
+
}
|
|
466
|
+
leaves.set(hostname, leaf);
|
|
467
|
+
return leaf;
|
|
468
|
+
},
|
|
469
|
+
};
|
|
470
|
+
}
|