@jr2/cli 0.2.0 → 0.3.0

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,306 @@
1
+ // The held-secret PKI (ADR-0059): the per-Instance CA and the leaves the Custodian ends TLS with.
2
+ //
3
+ // `jr2 up` issues every leaf BEFORE a pod starts, so the CA's private key never enters a Harness
4
+ // pod: it lives in the `jr2-held-ca` Secret, which no pod mounts, and `jr2 up` alone reads it over
5
+ // the kube API. A Custodian holds leaves and no key that could mint one.
6
+ //
7
+ // jr2 WRITES two fixed certificate shapes and nothing else, so this is a small DER writer over
8
+ // `node:crypto` rather than a dependency: the CLI's one runtime dependency is `ts-blank-space`, and
9
+ // the libraries that write X.509 bring a dozen packages (`@peculiar/x509`) or an ASN.1 parser and
10
+ // pure-JS RSA (`node-forge`) jr2 has no use for. Nor does it shell to `openssl`: that would add a
11
+ // binary to `jr2 up`'s floor, and macOS ships LibreSSL, whose flags differ. Node VERIFIES what this
12
+ // writes — the tests read every certificate back with `crypto.X509Certificate`, and the Custodian
13
+ // suite ends real TLS handshakes with them.
14
+ //
15
+ // CA CN=jr2 held-secret CA (<instance>) CA:true pathLen:0, keyCertSign+cRLSign, SKI 10 years
16
+ // leaf CN=<host> one DNS or IP SAN, CA:false, digitalSignature, 1 year
17
+ // serverAuth, AKI, SKI
18
+ //
19
+ // Keys are ECDSA P-256. Serials are 16 random bytes, top bit clear. `notBefore` is an hour back,
20
+ // for clock skew between this host and a node.
21
+
22
+ import {
23
+ createHash,
24
+ createPrivateKey,
25
+ generateKeyPairSync,
26
+ randomBytes,
27
+ sign,
28
+ X509Certificate,
29
+ type KeyObject,
30
+ } from "node:crypto";
31
+ import { isIP } from "node:net";
32
+ import { rootCertificates } from "node:tls";
33
+
34
+ /** One certificate and its key, both PEM. */
35
+ export type Pem = { cert: string; key: string };
36
+
37
+ const HOUR = 3_600_000;
38
+ const DAY = 24 * HOUR;
39
+ /** A leaf is issued again when fewer than this many days remain on it. */
40
+ const RENEW_BEFORE_DAYS = 30;
41
+
42
+ // ---- DER ----------------------------------------------------------------------------------------
43
+
44
+ function length(n: number): Buffer {
45
+ if (n < 0x80) return Buffer.from([n]);
46
+ const bytes: number[] = [];
47
+ for (let v = n; v > 0; v = Math.floor(v / 256)) bytes.unshift(v % 256);
48
+ return Buffer.from([0x80 | bytes.length, ...bytes]);
49
+ }
50
+
51
+ function tlv(tag: number, content: Buffer): Buffer {
52
+ return Buffer.concat([Buffer.from([tag]), length(content.length), content]);
53
+ }
54
+
55
+ const seq = (...parts: Buffer[]): Buffer => tlv(0x30, Buffer.concat(parts));
56
+ const set = (...parts: Buffer[]): Buffer => tlv(0x31, Buffer.concat(parts));
57
+ const octets = (content: Buffer): Buffer => tlv(0x04, content);
58
+ const bits = (content: Buffer, unused = 0): Buffer => tlv(0x03, Buffer.concat([Buffer.from([unused]), content]));
59
+ const bool = (value: boolean): Buffer => tlv(0x01, Buffer.from([value ? 0xff : 0x00]));
60
+ const utf8 = (value: string): Buffer => tlv(0x0c, Buffer.from(value, "utf8"));
61
+ /** A context-specific tag: constructed and EXPLICIT for `[0]`/`[3]` of the TBS, primitive and
62
+ * IMPLICIT for a SAN or a key identifier. */
63
+ const context = (n: number, content: Buffer, constructed: boolean): Buffer =>
64
+ tlv((constructed ? 0xa0 : 0x80) | n, content);
65
+
66
+ /** A DER INTEGER from its big-endian magnitude: minimal, and positive. */
67
+ function integer(magnitude: Buffer): Buffer {
68
+ let start = 0;
69
+ while (start < magnitude.length - 1 && magnitude[start] === 0 && (magnitude[start + 1] as number) < 0x80) start++;
70
+ const trimmed = magnitude.subarray(start);
71
+ return tlv(0x02, (trimmed[0] as number) >= 0x80 ? Buffer.concat([Buffer.from([0]), trimmed]) : trimmed);
72
+ }
73
+
74
+ function oid(dotted: string): Buffer {
75
+ const [a, b, ...rest] = dotted.split(".").map(Number) as [number, number, ...number[]];
76
+ const out = [40 * a + b];
77
+ for (const n of rest) {
78
+ const chunk: number[] = [n & 0x7f];
79
+ for (let v = Math.floor(n / 128); v > 0; v = Math.floor(v / 128)) chunk.unshift(0x80 | (v & 0x7f));
80
+ out.push(...chunk);
81
+ }
82
+ return tlv(0x06, Buffer.from(out));
83
+ }
84
+
85
+ /** UTCTime until 2049, GeneralizedTime from 2050 (RFC 5280 §4.1.2.5). */
86
+ function time(date: Date): Buffer {
87
+ const iso = date.toISOString().replace(/[-:T]/g, "").slice(0, 14);
88
+ return date.getUTCFullYear() < 2050 ? tlv(0x17, Buffer.from(`${iso.slice(2)}Z`)) : tlv(0x18, Buffer.from(`${iso}Z`));
89
+ }
90
+
91
+ const name = (cn: string): Buffer => seq(set(seq(oid("2.5.4.3"), utf8(cn))));
92
+ const ECDSA_SHA256 = seq(oid("1.2.840.10045.4.3.2"));
93
+
94
+ function extension(id: string, critical: boolean, value: Buffer): Buffer {
95
+ return seq(oid(id), ...(critical ? [bool(true)] : []), octets(value));
96
+ }
97
+
98
+ // ---- keys ---------------------------------------------------------------------------------------
99
+
100
+ /** A P-256 public key's uncompressed point — the subjectPublicKey bits a key identifier hashes. */
101
+ function ecPoint(publicKey: KeyObject): Buffer {
102
+ const jwk = publicKey.export({ format: "jwk" });
103
+ return Buffer.concat([
104
+ Buffer.from([0x04]),
105
+ Buffer.from(jwk.x as string, "base64url"),
106
+ Buffer.from(jwk.y as string, "base64url"),
107
+ ]);
108
+ }
109
+
110
+ function p256(): { publicKeyDer: Buffer; point: Buffer; keyPem: string } {
111
+ const pair = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
112
+ return {
113
+ publicKeyDer: pair.publicKey.export({ type: "spki", format: "der" }),
114
+ point: ecPoint(pair.publicKey),
115
+ keyPem: pair.privateKey.export({ type: "pkcs8", format: "pem" }) as string,
116
+ };
117
+ }
118
+
119
+ /** RFC 5280's key identifier, method 1: the SHA-1 of the subjectPublicKey bits. */
120
+ function keyId(point: Buffer): Buffer {
121
+ return createHash("sha1").update(point).digest();
122
+ }
123
+
124
+ function serial(): Buffer {
125
+ const bytes = randomBytes(16);
126
+ bytes[0] = (bytes[0] as number) & 0x7f || 0x01;
127
+ return bytes;
128
+ }
129
+
130
+ function pem(der: Buffer): string {
131
+ const b64 = der
132
+ .toString("base64")
133
+ .replace(/(.{64})/g, "$1\n")
134
+ .replace(/\n$/, "");
135
+ return `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----\n`;
136
+ }
137
+
138
+ /** Sign a TBSCertificate into a Certificate. */
139
+ function certificate(tbs: Buffer, signerKeyPem: string): Buffer {
140
+ const signature = sign("sha256", tbs, createPrivateKey(signerKeyPem));
141
+ return seq(tbs, ECDSA_SHA256, bits(signature));
142
+ }
143
+
144
+ // ---- the two shapes -----------------------------------------------------------------------------
145
+
146
+ /** The per-Instance CA: `CN=jr2 held-secret CA (<instance>)`, ten years. */
147
+ export function issueCa(instance: string, now: Date = new Date()): Pem {
148
+ const key = p256();
149
+ const subject = name(`jr2 held-secret CA (${instance})`);
150
+ const tbs = seq(
151
+ context(0, integer(Buffer.from([2])), true),
152
+ integer(serial()),
153
+ ECDSA_SHA256,
154
+ subject,
155
+ seq(time(new Date(now.getTime() - HOUR)), time(new Date(now.getTime() + 3650 * DAY))),
156
+ subject,
157
+ key.publicKeyDer,
158
+ context(
159
+ 3,
160
+ seq(
161
+ extension("2.5.29.19", true, seq(bool(true), integer(Buffer.from([0])))),
162
+ // keyCertSign (bit 5) + cRLSign (bit 6): 0b0000_0110, one unused bit.
163
+ extension("2.5.29.15", true, bits(Buffer.from([0x06]), 1)),
164
+ extension("2.5.29.14", false, octets(keyId(key.point))),
165
+ ),
166
+ true,
167
+ ),
168
+ );
169
+ return { cert: pem(certificate(tbs, key.keyPem)), key: key.keyPem };
170
+ }
171
+
172
+ /** A leaf for one bound host — a DNS name or an IP literal — signed by `ca`, one year. */
173
+ export function issueLeaf(ca: Pem, host: string, now: Date = new Date()): Pem {
174
+ const key = p256();
175
+ const caCert = new X509Certificate(ca.cert);
176
+ const caKeyId = keyId(ecPoint(caCert.publicKey));
177
+ const tbs = seq(
178
+ context(0, integer(Buffer.from([2])), true),
179
+ integer(serial()),
180
+ ECDSA_SHA256,
181
+ issuerName(caCert),
182
+ seq(time(new Date(now.getTime() - HOUR)), time(new Date(now.getTime() + 365 * DAY))),
183
+ name(host),
184
+ key.publicKeyDer,
185
+ context(
186
+ 3,
187
+ seq(
188
+ extension("2.5.29.17", false, seq(subjectAltName(host))),
189
+ extension("2.5.29.19", false, seq()),
190
+ // digitalSignature (bit 0): 0b1000_0000, seven unused bits.
191
+ extension("2.5.29.15", true, bits(Buffer.from([0x80]), 7)),
192
+ extension("2.5.29.37", false, seq(oid("1.3.6.1.5.5.7.3.1"))),
193
+ extension("2.5.29.35", false, seq(context(0, caKeyId, false))),
194
+ extension("2.5.29.14", false, octets(keyId(key.point))),
195
+ ),
196
+ true,
197
+ ),
198
+ );
199
+ return { cert: pem(certificate(tbs, ca.key)), key: key.keyPem };
200
+ }
201
+
202
+ /** The issuer, as the CA's own subject — read off its DER rather than rebuilt from its CN, so a CA
203
+ * this module did not write still chains. */
204
+ function issuerName(ca: X509Certificate): Buffer {
205
+ const der = ca.raw;
206
+ // Certificate → TBSCertificate → [0] version, serial, sigAlg, issuer, validity, SUBJECT.
207
+ const tbs = children(children(der, 0)[0] as Buffer, 0);
208
+ return tbs[5] as Buffer;
209
+ }
210
+
211
+ /** The DER elements inside one constructed element, whole (tag and length included). */
212
+ function children(der: Buffer, offset: number): Buffer[] {
213
+ const header = (at: number): { start: number; end: number } => {
214
+ const first = der[at + 1] as number;
215
+ if (first < 0x80) return { start: at + 2, end: at + 2 + first };
216
+ const n = first & 0x7f;
217
+ let len = 0;
218
+ for (let i = 0; i < n; i++) len = len * 256 + (der[at + 2 + i] as number);
219
+ return { start: at + 2 + n, end: at + 2 + n + len };
220
+ };
221
+ const outer = header(offset);
222
+ const out: Buffer[] = [];
223
+ for (let at = outer.start; at < outer.end; ) {
224
+ const inner = header(at);
225
+ out.push(der.subarray(at, inner.end));
226
+ at = inner.end;
227
+ }
228
+ return out;
229
+ }
230
+
231
+ function subjectAltName(host: string): Buffer {
232
+ const family = isIP(host);
233
+ if (family === 4) return context(7, Buffer.from(host.split(".").map(Number)), false);
234
+ if (family === 6) return context(7, ipv6Bytes(host), false);
235
+ return context(2, Buffer.from(host, "ascii"), false);
236
+ }
237
+
238
+ function ipv6Bytes(host: string): Buffer {
239
+ const [head = "", tail] = host.split("::");
240
+ const groups = (part: string) => (part ? part.split(":") : []);
241
+ const left = groups(head);
242
+ const right = tail === undefined ? [] : groups(tail);
243
+ const all = [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
244
+ return Buffer.from(all.flatMap((g) => [parseInt(g, 16) >> 8, parseInt(g, 16) & 0xff]));
245
+ }
246
+
247
+ // ---- reuse --------------------------------------------------------------------------------------
248
+
249
+ /**
250
+ * Does a leaf `jr2 up` already holds still serve this host? Only when its one SAN names exactly
251
+ * this host, it verifies against the CURRENT CA, its key is its own, and more than 30 days remain.
252
+ * Anything else — a moved host, a rotated CA, an expiring leaf — issues a new one.
253
+ */
254
+ export function leafFits(leaf: Pem, ca: Pem, host: string, now: Date = new Date()): boolean {
255
+ try {
256
+ const cert = new X509Certificate(leaf.cert);
257
+ const issuer = new X509Certificate(ca.cert);
258
+ if (!cert.verify(issuer.publicKey)) return false;
259
+ if (!cert.checkPrivateKey(createPrivateKey(leaf.key))) return false;
260
+ const want = isIP(host) === 0 ? `DNS:${host}` : undefined;
261
+ if (want !== undefined ? cert.subjectAltName !== want : cert.checkIP(host) === undefined) return false;
262
+ if (isIP(host) !== 0 && (cert.subjectAltName ?? "").split(",").length !== 1) return false;
263
+ return new Date(cert.validTo).getTime() - now.getTime() > RENEW_BEFORE_DAYS * DAY;
264
+ } catch {
265
+ return false;
266
+ }
267
+ }
268
+
269
+ /** A CA `jr2 up` found in the cluster is kept when it parses, is a CA, and its key is its own. */
270
+ export function caFits(ca: Pem, now: Date = new Date()): boolean {
271
+ try {
272
+ const cert = new X509Certificate(ca.cert);
273
+ return (
274
+ cert.ca && cert.checkPrivateKey(createPrivateKey(ca.key)) && new Date(cert.validTo).getTime() > now.getTime()
275
+ );
276
+ } catch {
277
+ return false;
278
+ }
279
+ }
280
+
281
+ /** SHA-256 of a certificate's DER — what the Instance Harness's held digest covers per leaf. */
282
+ export function fingerprint(certPem: string): string {
283
+ return new X509Certificate(certPem).fingerprint256;
284
+ }
285
+
286
+ // ---- trust --------------------------------------------------------------------------------------
287
+
288
+ /**
289
+ * The three bundles a held secret needs, beside the user's own `ca.crt` (ADR-0020, ADR-0059).
290
+ * "Roots" are the Mozilla roots of the Node that runs `jr2 up` — no file on the host, no dependency,
291
+ * and one list whatever engine verifies with it.
292
+ *
293
+ * extra.crt caBundle + the jr2 CA the Harness's NODE_EXTRA_CA_CERTS (adds to Node's roots)
294
+ * bundle.crt roots + both SSL_CERT_FILE and friends (these REPLACE a tool's store)
295
+ * upstream.crt roots + caBundle the Custodian's upstream trust — NEVER the jr2 CA, so
296
+ * it can never accept one of its own leaves from upstream
297
+ */
298
+ export function trustBundles(jr2Ca: string, caBundle?: string): { extra: string; bundle: string; upstream: string } {
299
+ const roots = rootCertificates.map((c) => (c.endsWith("\n") ? c : `${c}\n`)).join("");
300
+ const user = caBundle ? (caBundle.endsWith("\n") ? caBundle : `${caBundle}\n`) : "";
301
+ return {
302
+ extra: `${user}${jr2Ca}`,
303
+ bundle: `${roots}${user}${jr2Ca}`,
304
+ upstream: `${roots}${user}`,
305
+ };
306
+ }
package/src/index.ts CHANGED
@@ -10,3 +10,7 @@ export { assertKitVersion, checkKitVersion, resolvePackage, CLI_VERSION } from "
10
10
  export type { Target, TargetOptions } from "./instance.ts";
11
11
  export { kubectlKube } from "./kube.ts";
12
12
  export type { KubePort } from "./kube.ts";
13
+ // The held-secret PKI (ADR-0059): what `jr2 up` issues the Custodian's leaves with — exported so a
14
+ // test can issue its own fixtures the same way (the @kind tier's HTTPS model provider).
15
+ export { issueCa, issueLeaf } from "./held-pki.ts";
16
+ export type { Pem } from "./held-pki.ts";
package/src/kube.ts CHANGED
@@ -98,8 +98,11 @@ export type KubeAdmin = {
98
98
  allNamespaces?: boolean;
99
99
  context?: string;
100
100
  }): Promise<T[]>;
101
- /** `kubectl apply -f -` of a multi-doc YAML or JSON manifest string. */
102
- apply(opts: { manifest: string; context?: string }): Promise<void>;
101
+ /** `kubectl apply -f -` of a multi-doc YAML or JSON manifest string. `serverSide` is for an
102
+ * object too large for client-side apply's `last-applied-configuration` annotation (256 KiB) —
103
+ * the trust bundles, which carry every root certificate (ADR-0059); jr2 is its only writer, so it
104
+ * takes the fields it owns (`--force-conflicts`). */
105
+ apply(opts: { manifest: string; context?: string; serverSide?: boolean }): Promise<void>;
103
106
  /** `kubectl label --overwrite`. */
104
107
  label(opts: {
105
108
  kind: string;
@@ -238,8 +241,9 @@ export const kubectlAdmin: KubeAdmin = {
238
241
  return (JSON.parse(stdout) as { items?: never[] }).items ?? [];
239
242
  },
240
243
 
241
- async apply({ manifest, context }) {
242
- await execStdin(["kubectl", ...ctxArgs(context), "apply", "-f", "-"], manifest);
244
+ async apply({ manifest, context, serverSide }) {
245
+ const mode = serverSide ? ["--server-side", "--force-conflicts", "--field-manager=jr2"] : [];
246
+ await execStdin(["kubectl", ...ctxArgs(context), "apply", ...mode, "-f", "-"], manifest);
243
247
  },
244
248
 
245
249
  async label({ kind, name, namespace, labels, context }) {
package/src/sweep.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  // 2. every Sandbox CR's `spec.image` in those namespaces — a parked Workspace must survive a pod
11
11
  // restart, and `imagePullPolicy: IfNotPresent` cannot re-pull a local tag;
12
12
  // 3. every pod's container images in those namespaces plus `jr2-system` — what is actually running
13
- // (orchestrator, Instance Harness, Adapter, Sandboxes, operator), mid-roll pods INCLUDED,
13
+ // (orchestrator, Instance Harness, Custodians, Sandboxes, operator), mid-roll pods INCLUDED,
14
14
  // without naming Deployments one by one.
15
15
  // The keep set is their union. "Kit images are never pruned" is not a rule here: a kit ref is kept
16
16
  // because some instance's map or pod names it, and collects like anything else when the last
@@ -93,9 +93,9 @@ export async function readRoots(kube: KubeAdmin, ctx: { context?: string } = {})
93
93
  }
94
94
 
95
95
  // 2. what a parked Workspace's pod will be recreated with — the Sandbox Image AND every sidecar's
96
- // ref. The CR always carries the Adapter as a sidecar (sandbox-kubectl.ts), and its ref is not
96
+ // ref. The CR always carries the Custodian as a sidecar (sandbox-kubectl.ts), and its ref is not
97
97
  // covered by the other roots: a running Sandbox is deliberately never re-imaged, so an `up` that
98
- // rebuilt the Adapter leaves the CR naming the OLD one while the map names the new. If that pod
98
+ // moved the Custodian's pin leaves the CR naming the OLD one while the map names the new. If that pod
99
99
  // is then lost (node restart, eviction, drain), the recreated one pulls the CR's sidecar ref —
100
100
  // and `IfNotPresent` cannot re-pull a local tag a sweep took.
101
101
  const sandboxes = await listSandboxes(kube, ctx);
@@ -141,8 +141,8 @@ async function listSandboxes(kube: KubeAdmin, ctx: { context?: string }): Promis
141
141
  }
142
142
  }
143
143
 
144
- /** Every ref one instance's image map names: the kit's own at the top level (`harness`, `adapter`,
145
- * and the `operator` that rides the same JSON), and each Sandbox Image under `sandbox`. An
144
+ /** Every ref one instance's image map names: the kit's own at the top level (`harness`,
145
+ * `custodian`, and the `operator` that rides the same JSON), and each Sandbox Image under `sandbox`. An
146
146
  * unreadable map is a FAILED root, not an empty one — a hand-edited ConfigMap must not be read as
147
147
  * "that instance needs nothing". */
148
148
  function imageMapRefs(cm: ConfigMapObject): string[] {