insta 0.0.73 → 0.0.74

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,710 @@
1
+ // ssh_config and known_hosts editing. Pure string functions, deliberately: the
2
+ // traps here are all about WHERE text lands in a file the user also owns, and
3
+ // none of them is observable from a function that does its own I/O.
4
+ /** The fenced block this CLI owns. Everything between the markers is ours. */
5
+ export const BLOCK_BEGIN = '# BEGIN insta compute ssh';
6
+ export const BLOCK_END = '# END insta compute ssh';
7
+ /** Trailing marker on every known_hosts line we own, so rotation can replace
8
+ * our anchors without touching anchors the user added themselves. */
9
+ export const CA_MARKER = '# insta compute ssh';
10
+ /** The suffix that makes an alias ours: `api` -> `api.insta`. */
11
+ export const ALIAS_SUFFIX = '.insta';
12
+ // An alias is written verbatim into ssh_config AND into the argument of a
13
+ // shell-backed `Match exec` directive, so the set of legal characters has to be
14
+ // narrow enough that no quoting question can arise. This is the same charset
15
+ // SERVICE_NAME_RE allows in services.ts, plus the suffix.
16
+ const ALIAS_RE = /^[a-z0-9][a-z0-9-]{0,38}\.insta$/;
17
+ export function isSafeAlias(alias) {
18
+ return ALIAS_RE.test(alias);
19
+ }
20
+ /** A HostName/User value that cannot break out of its own directive.
21
+ *
22
+ * ssh_config is line-oriented and whitespace-separated, so a value carrying a
23
+ * space silently becomes a directive plus arguments, and one carrying a
24
+ * newline becomes an ENTIRELY NEW directive under our `Host` stanza. These
25
+ * come from an API response and from a store the reader deliberately tolerates
26
+ * being hand-edited, so neither is trusted input.
27
+ *
28
+ * A BACKSLASH is rejected for the same reason paths normalise it away:
29
+ * OpenSSH treats it as an escape introducer inside a config argument, so
30
+ * `HostName evil\.example` is not the host it appears to be. A value that is
31
+ * accepted here must survive to ssh as exactly one literal directive value. */
32
+ export function isSafeConfigValue(v) {
33
+ // eslint-disable-next-line no-control-regex
34
+ return typeof v === 'string' && v.length > 0 && v.length <= 253 && !/[\s\u0000-\u001f\u007f"'\\]/.test(v);
35
+ }
36
+ /** ssh_config's own quoting for a path.
37
+ *
38
+ * Paths are the one field a user does not choose and cannot avoid: a home
39
+ * directory with a space in it -- ordinary on Windows and not rare on macOS --
40
+ * turns `IdentityFile /Users/First Last/.insta/...` into a directive with two
41
+ * arguments, and OpenSSH then rejects the WHOLE FILE. Every alias the user has
42
+ * stops working, not just ours.
43
+ *
44
+ * A literal double quote is refused rather than escaped, because ssh_config
45
+ * has no escape for one inside a quoted argument -- there is no correct string
46
+ * to emit, so emitting nothing and saying why is the only honest answer.
47
+ *
48
+ * BACKSLASHES are normalised to forward slashes, which is not cosmetic. A
49
+ * Windows path reaches us as `C:\Users\...`, and OpenSSH treats a backslash in
50
+ * a config argument as an escape introducer -- so `\U` is consumed and the
51
+ * path silently becomes a different one. Windows OpenSSH accepts forward
52
+ * slashes everywhere, so rewriting is both safe and the only unambiguous
53
+ * form.
54
+ *
55
+ * QUOTING DOES NOT MAKE A PATH LITERAL. OpenSSH expands tokens inside the
56
+ * quotes for IdentityFile and CertificateFile, on the connect path rather than
57
+ * at parse time -- which is why `ssh -G` shows nothing wrong. So a `%` in an
58
+ * ordinary home directory is not a character, it is syntax: `/home/dev%team`
59
+ * makes ssh abort the whole connection on an unknown token, and `/home/%d/...`
60
+ * quietly resolves to somewhere else entirely. `%%` is the escape, and it is
61
+ * applied to the path we were handed -- never to the ControlPath tokens, which
62
+ * we write ourselves and do not route through here.
63
+ *
64
+ * `${...}` gets no such treatment because ssh_config has no escape for it:
65
+ * a defined variable rewrites the filename and an undefined one aborts, and
66
+ * there is no third spelling that means "a dollar sign followed by a brace".
67
+ * Refusing is the only honest answer, as with the double quote above. A lone
68
+ * `$` is not expansion syntax and stays a filename. */
69
+ export function quoteConfigPath(path) {
70
+ if (path.includes('"'))
71
+ throw new Error(`cannot write an ssh_config path containing a double quote: ${JSON.stringify(path)}`);
72
+ if (/[\n\r]/.test(path))
73
+ throw new Error(`cannot write an ssh_config path containing a newline: ${JSON.stringify(path)}`);
74
+ if (path.includes('${'))
75
+ throw new Error(`cannot write an ssh_config path containing an environment-variable expansion: ${JSON.stringify(path)}`);
76
+ return `"${path.replace(/\\/g, '/').replace(/%/g, '%%')}"`;
77
+ }
78
+ /**
79
+ * The ssh alias for a compute service.
80
+ *
81
+ * Throws rather than sanitising: a silently rewritten alias would point `ssh
82
+ * api.insta` at a stanza that is not the service the user named.
83
+ */
84
+ export function aliasFor(serviceName) {
85
+ const alias = serviceName + ALIAS_SUFFIX;
86
+ if (!isSafeAlias(alias))
87
+ throw new Error(`cannot build an ssh alias for service name ${JSON.stringify(serviceName)}`);
88
+ return alias;
89
+ }
90
+ /**
91
+ * The `Match` line that renews a certificate while OpenSSH parses the config.
92
+ *
93
+ * The alias is baked in as a literal and validated first; it is never `%h`.
94
+ * OpenSSH expands its tokens BEFORE handing the string to the user's shell, and
95
+ * ssh_config(5) warns that expansions used by shell-backed directives must be
96
+ * safely handled — with `%h` a crafted hostname is shell syntax in our command.
97
+ * A literal from ALIAS_RE cannot contain any.
98
+ *
99
+ * `originalhost`, not `host`: `host` matches AFTER HostName substitution, so it
100
+ * would see the real hostname rather than the alias and never fire.
101
+ */
102
+ export function renderEnsureCertMatch(alias, command) {
103
+ if (!isSafeAlias(alias))
104
+ throw new Error(`refusing to write an unsafe ssh alias into ssh_config: ${JSON.stringify(alias)}`);
105
+ return `Match originalhost ${alias} exec "${command} ${alias}"`;
106
+ }
107
+ export function renderConfigBlock(o) {
108
+ const lines = [BLOCK_BEGIN];
109
+ for (const e of o.entries) {
110
+ if (!isSafeAlias(e.alias))
111
+ throw new Error(`refusing to write an unsafe ssh alias into ssh_config: ${JSON.stringify(e.alias)}`);
112
+ // The alias was already checked; these two were not, and they reach this
113
+ // file verbatim from an API response.
114
+ if (!isSafeConfigValue(e.hostName))
115
+ throw new Error(`refusing to write an unsafe ssh HostName into ssh_config: ${JSON.stringify(e.hostName)}`);
116
+ if (!isSafeConfigValue(e.user))
117
+ throw new Error(`refusing to write an unsafe ssh User into ssh_config: ${JSON.stringify(e.user)}`);
118
+ lines.push(`Host ${e.alias}`,
119
+ // Without HostName and User the alias is not routing at all: ssh resolves
120
+ // `api.insta` in DNS and logs in as the local OS username.
121
+ ` HostName ${e.hostName}`, ` User ${e.user}`, ` IdentityFile ${quoteConfigPath(o.identityFile)}`, ` CertificateFile ${quoteConfigPath(e.certificateFile)}`,
122
+ // WRITTEN, not left to the default, and this is the one keyword where
123
+ // being first in the file does not save us. First-wins settles a keyword
124
+ // two blocks both set; a keyword we never set at all goes on being filled
125
+ // in by later matching blocks. So a `Host *` further down carrying
126
+ // `UserKnownHostsFile none` -- ordinary in a hardened config -- or a
127
+ // custom path takes effect for OUR alias, and the CA that setup installed
128
+ // in known_hosts is then never consulted. The user gets a host-key prompt
129
+ // or a flat refusal on the one connection they were told needs no
130
+ // host-key management.
131
+ //
132
+ // Naming one file deliberately drops OpenSSH's second default,
133
+ // ~/.ssh/known_hosts2 -- a v1-era legacy path we never write to. Pinning
134
+ // the file the anchor is really in is the property; inheriting a list we
135
+ // do not control is what we are getting away from.
136
+ ` UserKnownHostsFile ${quoteConfigPath(o.knownHostsFile)}`,
137
+ // IdentitiesOnly is not tidiness. SSH offers public keys ONE AT A TIME,
138
+ // so a user with several keys is identified non-deterministically -- the
139
+ // server sees whichever key happened to be offered first, which may not be
140
+ // the one carrying our certificate. exe.dev calls this heisen-connect.
141
+ // Without this line a developer with a full ssh-agent gets intermittent,
142
+ // unexplainable auth failures.
143
+ ' IdentitiesOnly yes');
144
+ // Connection multiplexing collapses scp, an IDE's several connections and a
145
+ // second terminal onto ONE connection; without it a single developer can
146
+ // reach the per-service session cap in an afternoon.
147
+ //
148
+ // OMITTED ON WINDOWS, where it is not an optimisation but a broken config.
149
+ // Win32-OpenSSH does not implement ControlMaster (PowerShell/Win32-OpenSSH
150
+ // #1328, #405) and fails the connection rather than ignoring the directive,
151
+ // and the ControlPath itself contains a `:` before %p, which is not a legal
152
+ // character in a Windows filename. Every alias would be unusable on a
153
+ // platform this repo runs CI for. The effective-config tests need a real
154
+ // `ssh` and skip on Windows, so this branch is asserted on the rendered
155
+ // text instead.
156
+ if ((o.platform ?? process.platform) !== 'win32') {
157
+ lines.push(' ControlMaster auto', ' ControlPath ~/.insta/ssh/cm-%r@%h:%p', ' ControlPersist 10m');
158
+ }
159
+ if (o.ensureCertCommand) {
160
+ // Renewal happens while OpenSSH PARSES the config, before it connects, so
161
+ // a certificate that expired since the last login is replaced silently
162
+ // rather than surfacing as a refused login. Without it, "after setup it is
163
+ // just ssh" stops being true the moment the first certificate expires.
164
+ lines.push(renderEnsureCertMatch(e.alias, o.ensureCertCommand));
165
+ }
166
+ }
167
+ // Closes our last stanza. Without it everything the user wrote at the top of
168
+ // their own config -- which we insert ABOVE -- stops being unconditional and
169
+ // silently becomes part of our final `Host`/`Match` block instead.
170
+ lines.push('Match all', BLOCK_END);
171
+ return lines.join('\n') + '\n';
172
+ }
173
+ /**
174
+ * Insert or replace our block in an ssh_config.
175
+ *
176
+ * AT THE TOP, never appended, and this is the whole reason the function
177
+ * exists. OpenSSH takes the FIRST obtained value for each keyword, and
178
+ * ssh_config(5) says outright that host-specific declarations belong near the
179
+ * beginning of the file. A block appended at the end loses every keyword to an
180
+ * earlier `Host *` -- silently, with no error and no warning, producing a
181
+ * connection that ignores the IdentityFile we just wrote.
182
+ *
183
+ * Idempotent: an existing block is replaced in place rather than duplicated.
184
+ */
185
+ export function upsertConfigBlock(existing, block) {
186
+ // The old block is CUT from wherever it sits and the new one is PREPENDED --
187
+ // it is never replaced where it stands. Replacing in place looks equivalent
188
+ // and is not: a block that ended up below an earlier `Host *` (an older
189
+ // version of this CLI appended it, or the user moved it) would keep that
190
+ // offset forever, and first-wins means every keyword in it is ignored. The
191
+ // symptom is the worst kind: ssh connects, silently using the wrong identity,
192
+ // with no error to search for. Re-running --setup has to be able to FIX that
193
+ // file, which means the position is part of what we upsert.
194
+ // Leading blank lines are stripped from what remains: cutting the block out
195
+ // of the top of a file leaves the separator behind, and re-prepending would
196
+ // then add one MORE every run, growing the user's config forever.
197
+ const rest = removeOwnedBlock(existing).replace(/^\n+/, '');
198
+ if (rest.trim() === '')
199
+ return block;
200
+ return block + '\n' + rest;
201
+ }
202
+ /** Is our block live in this ssh_config?
203
+ *
204
+ * The block is rendered from the WHOLE alias store and replaced wholesale, so
205
+ * its presence is what makes the store the thing `ssh <alias>` actually reads.
206
+ * That is the signal a plain (non-`--setup`) issuance needs: once the block is
207
+ * there, changing the store without re-rendering it leaves the two disagreeing
208
+ * about where an alias points. The BEGIN marker alone is enough -- a file
209
+ * edited down to half a block is still a file we own a block in. */
210
+ export function hasOwnedBlock(existing) {
211
+ return existing.split('\n').some(isMarkerLine(BLOCK_BEGIN));
212
+ }
213
+ /** A marker is a WHOLE LINE, never a substring of one.
214
+ *
215
+ * Matching the marker text wherever it occurred made a user's comment that
216
+ * merely mentioned it -- documentation of this very block, one line above
217
+ * it -- the start of "our" block: everything from the middle of that line to
218
+ * the real end marker was cut, the user's stanzas in between included. The
219
+ * same substring made a plain issuance believe a block was installed. A line
220
+ * that IS the marker is ours; a line that contains it is theirs. */
221
+ const isMarkerLine = (marker) => (line) => line.trim() === marker;
222
+ /** `existing` with our fenced block cut out, wherever it was -- every
223
+ * well-formed one, so a file that somehow holds two comes back with one. */
224
+ function removeOwnedBlock(existing) {
225
+ const lines = existing.split('\n');
226
+ const isBegin = isMarkerLine(BLOCK_BEGIN), isEnd = isMarkerLine(BLOCK_END);
227
+ const out = [];
228
+ for (let i = 0; i < lines.length; i++) {
229
+ if (isBegin(lines[i])) {
230
+ const end = lines.findIndex((l, n) => n > i && isEnd(l));
231
+ // The end marker's own line goes with the block, newline included, so
232
+ // repeated runs do not accumulate blank lines.
233
+ if (end !== -1) {
234
+ i = end;
235
+ continue;
236
+ }
237
+ // A begin marker with no end is a file someone edited by hand. Leave it
238
+ // -- and everything after it -- alone rather than guessing where our
239
+ // block stopped; the fresh block goes on top, and first-wins means it
240
+ // takes effect either way.
241
+ out.push(...lines.slice(i));
242
+ break;
243
+ }
244
+ out.push(lines[i]);
245
+ }
246
+ return out.join('\n');
247
+ }
248
+ /** A hostname we are willing to derive a trust anchor from.
249
+ *
250
+ * Stricter than isSafeConfigValue, and for a different file: known_hosts is
251
+ * newline-delimited with no fencing, so an unvalidated value does not corrupt
252
+ * ONE line, it appends whatever it likes -- including a broader
253
+ * `@cert-authority *` that would make the attacker's CA trusted for every host
254
+ * the user ever ssh's to. This value arrives in an HTTP response body, so it
255
+ * is checked before it reaches the file, not after. */
256
+ const SSH_HOSTNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/i;
257
+ export function isSafeSSHHost(v) {
258
+ return typeof v === 'string' && v.length > 0 && v.length <= 253 && SSH_HOSTNAME_RE.test(v);
259
+ }
260
+ /** The wire shape of each supported key type's public blob, after the type
261
+ * name that opens every blob. Walking the fields proves the blob is a
262
+ * sequence of well-formed fields; it does not prove they are the fields of
263
+ * the type on the label, and a correct type name followed by the wrong
264
+ * number of fields, a curve that disagrees with the name or a point of the
265
+ * wrong size installs an anchor ssh then refuses at connect time -- where the
266
+ * message points at known_hosts rather than at the response that produced it.
267
+ * ed25519 is the type we issue; the rest are what OpenSSH accepts as a CA,
268
+ * each held to what its own format requires:
269
+ *
270
+ * - ssh-ed25519: one 32-byte key.
271
+ * - ssh-rsa: mpint e, mpint n; OpenSSH refuses a modulus under 1024 bits.
272
+ * - ecdsa-sha2-nistpN: the curve name, which must be the one in the type,
273
+ * then an uncompressed point (0x04, then two coordinates of the curve's
274
+ * size).
275
+ * - sk-*@openssh.com (FIDO): the same key material as the plain type, then
276
+ * the application string.
277
+ *
278
+ * Field lengths, not field contents: the check is that the blob has the
279
+ * structure ssh will parse, not that it is a strong key. */
280
+ //
281
+ // A Map, not an object: the type is a string from an HTTP response, and on a
282
+ // plain object `shapes['constructor']` is Object -- a function, which called
283
+ // on the fields returns them, truthy -- so a key typed `constructor` passed as
284
+ // supported. A Map has no inherited entries to find.
285
+ const CA_KEY_SHAPES = new Map([
286
+ ['ssh-ed25519', (f) => f.length === 2 && f[1].length === 32],
287
+ ['ssh-rsa', (f) => f.length === 3 && f[1].length > 0 && f[2].length >= 128],
288
+ ['ecdsa-sha2-nistp256', (f) => f.length === 3 && isEcdsaBody(f[1], f[2], 'nistp256', 65)],
289
+ ['ecdsa-sha2-nistp384', (f) => f.length === 3 && isEcdsaBody(f[1], f[2], 'nistp384', 97)],
290
+ ['ecdsa-sha2-nistp521', (f) => f.length === 3 && isEcdsaBody(f[1], f[2], 'nistp521', 133)],
291
+ ['sk-ssh-ed25519@openssh.com', (f) => f.length === 3 && f[1].length === 32 && f[2].length > 0],
292
+ ['sk-ecdsa-sha2-nistp256@openssh.com', (f) => f.length === 4 && isEcdsaBody(f[1], f[2], 'nistp256', 65) && f[3].length > 0],
293
+ ]);
294
+ function isEcdsaBody(curve, point, wantCurve, pointLen) {
295
+ return curve.toString('utf8') === wantCurve && point.length === pointLen && point[0] === 0x04;
296
+ }
297
+ /** Exactly ONE OpenSSH public-key record: `<type> <base64>` with an optional
298
+ * comment, and nothing else -- no second line, no leading directive.
299
+ *
300
+ * `renderCertAuthority` only trimmed, so an embedded newline in the CA value
301
+ * smuggled additional known_hosts lines past it. Parsing to the three fields
302
+ * we will actually write, and rebuilding the line from THOSE, means a value
303
+ * either is one key record or is refused; there is no third outcome where
304
+ * part of it is honoured. */
305
+ export function parseCAPublicKey(value) {
306
+ if (typeof value !== 'string')
307
+ throw new Error('the platform returned no ssh certificate authority key');
308
+ const line = value.trim();
309
+ if (/[\n\r]/.test(line))
310
+ throw new Error('refusing a certificate authority key spanning multiple lines');
311
+ const parts = line.split(/[ \t]+/);
312
+ if (parts.length < 2)
313
+ throw new Error(`refusing a malformed certificate authority key: ${JSON.stringify(line.slice(0, 64))}`);
314
+ const type = parts[0], blob = parts[1];
315
+ const shape = CA_KEY_SHAPES.get(type);
316
+ if (!shape)
317
+ throw new Error(`refusing a certificate authority key of unsupported type ${JSON.stringify(type.slice(0, 32))}`);
318
+ if (!/^[A-Za-z0-9+/]+={0,3}$/.test(blob) || blob.length < 32) {
319
+ throw new Error('refusing a certificate authority key whose body is not base64');
320
+ }
321
+ // The blob's OWN type must agree with the text field. Base64-shaped is not
322
+ // the same as "is a key": an anchor built from a mislabelled or arbitrary
323
+ // blob installs silently and then fails at connect time, where the message
324
+ // points at known_hosts rather than at the response that produced it.
325
+ // The WHOLE blob, not just its first field. A first-field check rejects
326
+ // arbitrary base64 and still accepts a correct type name followed by noise --
327
+ // and an anchor built from that installs silently, then fails at connect
328
+ // time, where the message points at known_hosts rather than at the response
329
+ // that produced it.
330
+ const fields = sshBlobFields(blob);
331
+ if (!fields || fields.length < 2 || fields[0].toString('utf8') !== type) {
332
+ throw new Error(`refusing a certificate authority key whose body does not match its type ${JSON.stringify(type.slice(0, 32))}`);
333
+ }
334
+ // And the fields must be the ones THIS type has. ed25519 was the only type
335
+ // held to its shape at first, so a correct RSA or ECDSA type name followed by
336
+ // any well-formed fields passed -- see CA_KEY_SHAPES.
337
+ if (!shape(fields)) {
338
+ throw new Error(`refusing a certificate authority key whose body is not the shape of ${JSON.stringify(type.slice(0, 32))}`);
339
+ }
340
+ return { type, blob };
341
+ }
342
+ /** Exactly ONE OpenSSH CERTIFICATE record.
343
+ *
344
+ * A certificate is not a key: it is `<keytype>-cert-v01@openssh.com <base64>`.
345
+ * Accepting any non-empty string meant a response of `"new-cert"` replaced a
346
+ * working alias's live credential and only failed later, inside OpenSSH, with
347
+ * a message pointing at the file rather than at the plane that sent it.
348
+ *
349
+ * Checked structurally rather than by shelling out to `ssh-keygen -L`: this
350
+ * runs on the renewal path OpenSSH invokes while parsing its config, and
351
+ * adding a subprocess there trades one hazard for a slower one. The structure
352
+ * is what decides whether the file can be parsed at all, which is the property
353
+ * worth having before overwriting a working credential. */
354
+ const CERT_TYPE_RE = /^[a-z0-9@.-]+-cert-v01@openssh\.com$/i;
355
+ export function isSSHCertificateRecord(v) {
356
+ if (typeof v !== 'string')
357
+ return false;
358
+ const line = v.trim();
359
+ if (line === '' || /[\n\r]/.test(line))
360
+ return false;
361
+ const parts = line.split(/[ \t]+/);
362
+ if (parts.length < 2)
363
+ return false;
364
+ const type = parts[0], blob = parts[1];
365
+ if (!CERT_TYPE_RE.test(type))
366
+ return false;
367
+ if (!/^[A-Za-z0-9+/]+={0,3}$/.test(blob) || blob.length < 64)
368
+ return false;
369
+ // DECODED, not just shape-checked. `<valid type> AAAA...` of the right length
370
+ // is trivially constructible and passed every textual test while still being
371
+ // unusable -- and the cost of accepting it is that a working alias's live
372
+ // credential has already been replaced by the time ssh says so.
373
+ //
374
+ // An OpenSSH certificate blob begins with an SSH `string`: a 4-byte
375
+ // big-endian length followed by that many bytes, holding the certificate's
376
+ // own type name. It must agree with the type in the text field; a blob that
377
+ // does not even carry a well-formed first field is not a certificate at all.
378
+ // Decoding it here keeps the check dependency-free and off the subprocess
379
+ // path, which matters because this runs during OpenSSH's own config parse.
380
+ return sshBlobTypeName(blob) === type;
381
+ }
382
+ /** The ed25519 certificate type, and the only one we ever ask to be issued:
383
+ * ensureKeyPair generates ed25519 and nothing else, so a certificate of any
384
+ * other type cannot be a certificate for our key. */
385
+ const ED25519_CERT_TYPE = 'ssh-ed25519-cert-v01@openssh.com';
386
+ const ED25519_KEY_TYPE = 'ssh-ed25519';
387
+ /**
388
+ * Whether `certRecord` certifies exactly the key in `publicKeyRecord`.
389
+ *
390
+ * `ssh-keygen -L` proves a response is a parseable certificate; it does not
391
+ * prove it is OURS. A valid certificate for somebody else's key passes every
392
+ * other gate, replaces the working credential at `<alias>-cert.pub`, and then
393
+ * fails at authentication time -- where the message points at the file rather
394
+ * than at the response that produced it. Comparing the certified key material
395
+ * against ~/.insta/ssh/id_ed25519.pub is what closes that.
396
+ *
397
+ * Compared as KEY MATERIAL rather than as an ssh-keygen fingerprint: `-L`
398
+ * prints the fingerprint of the certificate blob, not of the key inside it, so
399
+ * there is nothing there to compare against a plain public key.
400
+ *
401
+ * An OpenSSH ed25519 certificate is `string type, string nonce, string pk, ...`
402
+ * and a plain ed25519 key is `string type, string pk`, so the comparison is
403
+ * field 2 against field 1. Only the first three fields are walked, because the
404
+ * uint64 serial that follows is not an SSH `string` and a full walk would
405
+ * misparse it.
406
+ */
407
+ export function certifiesPublicKey(certRecord, publicKeyRecord) {
408
+ if (typeof certRecord !== 'string' || typeof publicKeyRecord !== 'string')
409
+ return false;
410
+ const cert = certRecord.trim().split(/[ \t]+/);
411
+ const pub = publicKeyRecord.trim().split(/[ \t]+/);
412
+ if (cert[0] !== ED25519_CERT_TYPE || pub[0] !== ED25519_KEY_TYPE)
413
+ return false;
414
+ if (cert.length < 2 || pub.length < 2)
415
+ return false;
416
+ const certFields = sshBlobFields(cert[1], 3);
417
+ const pubFields = sshBlobFields(pub[1]);
418
+ if (!certFields || certFields.length !== 3 || !pubFields || pubFields.length !== 2)
419
+ return false;
420
+ // The blobs' OWN type names, for the same reason parseCAPublicKey reads them:
421
+ // the text field is a label anyone can write.
422
+ if (certFields[0].toString('utf8') !== cert[0] || pubFields[0].toString('utf8') !== pub[0])
423
+ return false;
424
+ const certified = certFields[2], key = pubFields[1];
425
+ return key.length === 32 && certified.equals(key);
426
+ }
427
+ /** The type name an SSH key/certificate blob declares about ITSELF.
428
+ *
429
+ * Every OpenSSH blob begins with an SSH `string`: a 4-byte big-endian length
430
+ * followed by that many bytes, holding the algorithm name. Reading it is what
431
+ * separates "base64 of the right length" -- which anyone can construct -- from
432
+ * a blob that is at least the kind of thing it claims to be. Returns undefined
433
+ * when the blob does not even carry a well-formed first field. */
434
+ export function sshBlobTypeName(blob) {
435
+ const fields = sshBlobFields(blob, 1);
436
+ return fields?.[0]?.toString('utf8');
437
+ }
438
+ /** Every SSH `string` field in a blob, or undefined if it is not well-formed.
439
+ *
440
+ * The wire format is a sequence of 4-byte big-endian lengths each followed by
441
+ * that many bytes. Requiring the walk to land EXACTLY on the end is what makes
442
+ * this a structural check rather than a prefix check: trailing noise, a length
443
+ * that overruns the buffer, and a truncated final field are all rejected.
444
+ *
445
+ * `limit` stops after that many fields, for callers that only need the head. */
446
+ export function sshBlobFields(blob, limit = Infinity) {
447
+ let raw;
448
+ try {
449
+ raw = Buffer.from(blob, 'base64');
450
+ }
451
+ catch {
452
+ return undefined;
453
+ }
454
+ const out = [];
455
+ let at = 0;
456
+ while (at < raw.length && out.length < limit) {
457
+ if (raw.length - at < 4)
458
+ return undefined;
459
+ const len = raw.readUInt32BE(at);
460
+ // Bounds-checked BEFORE being used as an offset, and capped so a hostile
461
+ // length cannot drive a huge allocation.
462
+ if (len > 65_536 || raw.length - at - 4 < len)
463
+ return undefined;
464
+ out.push(raw.subarray(at + 4, at + 4 + len));
465
+ at += 4 + len;
466
+ }
467
+ if (out.length === 0)
468
+ return undefined;
469
+ // No trailing-bytes check here on purpose: `at` only ever advances by a whole
470
+ // consumed field, and the two guards inside the loop reject every partial
471
+ // tail, so on a full walk the loop can only exit with at === raw.length. A
472
+ // final `at === raw.length ? ... : undefined` reads like a safety net and is
473
+ // a condition that can never be false -- worse than no check, because the
474
+ // next reader trusts it.
475
+ return out;
476
+ }
477
+ /** An SSH principal safe to place in a command line.
478
+ *
479
+ * Chiefly: NEVER a leading `-`. Shell quoting does not help here, because the
480
+ * hazard is not the shell -- `ssh` parses its own argv, so a destination of
481
+ * `-oProxyCommand=id` is read as an OPTION however carefully it was quoted,
482
+ * and the user pasting the advertised command runs it. */
483
+ export function isSafeSSHUsername(v) {
484
+ return isSafeConfigValue(v) && /^[A-Za-z0-9_][A-Za-z0-9_.@-]*$/.test(v) && v.length <= 64;
485
+ }
486
+ /** A timestamp we are willing to print to a terminal. Rejects the control
487
+ * characters and escape sequences that would let a response repaint the
488
+ * screen or hide what it actually said. */
489
+ export function isSafeTimestamp(v) {
490
+ return typeof v === 'string' && v.length > 0 && v.length <= 64
491
+ && !/[\u0000-\u001f\u007f]/.test(v) && !Number.isNaN(Date.parse(v));
492
+ }
493
+ /** The trust anchor line for known_hosts, tagged as ours. */
494
+ export function renderCertAuthority(hostPattern, caKey) {
495
+ // Rebuilt from the PARSED fields rather than interpolating what we were
496
+ // handed: that is what makes "exactly one key record" a property of the
497
+ // output instead of a hope about the input.
498
+ const { type, blob } = parseCAPublicKey(caKey);
499
+ if (!isSafeCAHostPattern(hostPattern)) {
500
+ throw new Error(`refusing a certificate authority host pattern: ${JSON.stringify(String(hostPattern).slice(0, 64))}`);
501
+ }
502
+ return `@cert-authority ${hostPattern} ${type} ${blob} ${CA_MARKER}\n`;
503
+ }
504
+ /** A host pattern narrow enough to anchor a CA to.
505
+ *
506
+ * A `@cert-authority` line tells ssh "this CA may vouch for any host matching
507
+ * this pattern", so the pattern is the blast radius. Three rules, and the
508
+ * third is the one that matters:
509
+ *
510
+ * 1. No bare `*`, and at most one wildcard label -- `@cert-authority *` makes
511
+ * the platform's CA authoritative for github.com and everything else.
512
+ * 2. The wildcard is never the FIRST label: `*.com` is the same hole.
513
+ * 3. At least TWO fixed labels must follow the wildcard. This is what keeps
514
+ * the pattern inside a domain the gateway actually occupies. Counting
515
+ * total labels is not enough: `ssh.*.com` has three labels and is a
516
+ * catastrophe -- it makes the CA authoritative for ssh.vendor.com,
517
+ * ssh.google.com and every other `ssh.<anything>.com`. Requiring two
518
+ * labels after the wildcard means the wildcard can only ever range over a
519
+ * sub-label of a specific registered domain.
520
+ *
521
+ * A pattern with NO wildcard is an exact host and needs only to be a hostname. */
522
+ /** Gateway domains whose region label may be collapsed to a wildcard.
523
+ *
524
+ * An allowlist, because the alternative is guessing where the registrable
525
+ * domain ends, and that guess has no safe default. Requiring two labels after
526
+ * the wildcard is NOT enough: `ssh.*.co.uk` has two and still ranges across
527
+ * every co.uk registrant, because `co.uk` is a public suffix rather than
528
+ * somebody's domain. Distinguishing those needs the public-suffix list, which
529
+ * is a dependency and a moving target.
530
+ *
531
+ * So we widen only under suffixes we know we own, and every other deployment
532
+ * -- self-hosted, staging, a name we have not seen -- gets an EXACT anchor per
533
+ * region. That costs one known_hosts line per region and is never wrong, which
534
+ * is the right side to err on for a trust anchor. */
535
+ export const CA_WIDENABLE_SUFFIXES = [
536
+ 'compute.instacloud.tech',
537
+ 'compute.insforge.dev',
538
+ ];
539
+ /** Whether `host`'s region label may be replaced by a wildcard.
540
+ *
541
+ * `suffixes` is a parameter so the RULE can be tested apart from the LIST:
542
+ * the list is deployment configuration that will change, the rule is the
543
+ * security property and must not. */
544
+ export function mayWidenCAHost(host, suffixes = CA_WIDENABLE_SUFFIXES) {
545
+ const under = suffixes.find((suffix) => host.endsWith(`.${suffix}`));
546
+ if (!under)
547
+ return false;
548
+ // The shape the wildcard assumes -- <gateway>.<region>.<suffix> -- so the
549
+ // label being widened is genuinely the region and not part of the suffix.
550
+ const head = host.slice(0, host.length - under.length - 1).split('.');
551
+ if (head.length !== 2)
552
+ return false;
553
+ // And the first label must be the SSH GATEWAY, which is the scope this
554
+ // anchor was always meant to have. Tenant service hostnames live under the
555
+ // same suffix, so widening `api.us-west-1.<suffix>` to `api.*.<suffix>`
556
+ // would let the CA vouch for an unrelated platform host that merely shares
557
+ // the shape -- the exact over-scoping the wildcard was introduced to avoid.
558
+ return head[0] === SSH_GATEWAY_LABEL;
559
+ }
560
+ /** The first label of every SSH gateway name, and the only first label a
561
+ * wildcard anchor may carry. */
562
+ export const SSH_GATEWAY_LABEL = 'ssh';
563
+ export function isSafeCAHostPattern(v) {
564
+ if (typeof v !== 'string' || v.length === 0 || v.length > 253)
565
+ return false;
566
+ const labels = v.split('.');
567
+ if (labels.length < 2)
568
+ return false;
569
+ if (!labels.every((l) => l === '*' || /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i.test(l)))
570
+ return false;
571
+ const star = labels.indexOf('*');
572
+ if (star === -1)
573
+ return true;
574
+ if (labels.filter((l) => l === '*').length > 1)
575
+ return false;
576
+ if (star === 0)
577
+ return false;
578
+ return labels.length - star - 1 >= 2;
579
+ }
580
+ /**
581
+ * Install the trust anchor in known_hosts, replacing the one we installed before.
582
+ *
583
+ * Appended rather than inserted, unlike the config block: known_hosts has no
584
+ * first-wins rule -- every line is considered -- so position carries no meaning
585
+ * here.
586
+ *
587
+ * Rotation policy: we own at most ONE anchor per host pattern. A line of OURS
588
+ * (it carries CA_MARKER) is dropped when it covers the same host pattern -- so
589
+ * a rotated CA replaces the retired one instead of leaving it trusted forever
590
+ * -- or when it carries the same key AND the two patterns cover the same hosts,
591
+ * so a pattern that merely widened or narrowed moves the anchor rather than
592
+ * duplicating it. Anchors the user added themselves have no marker and are
593
+ * never touched.
594
+ */
595
+ export function upsertCertAuthority(existing, hostPattern, caKey) {
596
+ return planCertAuthority(existing, hostPattern, caKey).next;
597
+ }
598
+ /**
599
+ * What an anchor install would write, and what it would retire to do it.
600
+ *
601
+ * Split out from upsertCertAuthority because a rotation is only half of a
602
+ * larger change -- the certificate signed by the new CA still has to be
603
+ * committed -- and the second half can fail. Retiring the old anchor is the
604
+ * step that BREAKS an alias which worked a moment ago: the old certificate is
605
+ * still installed and the CA that vouches for it is gone. So the caller is
606
+ * handed the retired lines and can put them back; see revertCertAuthority.
607
+ */
608
+ export function planCertAuthority(existing, hostPattern, caKey) {
609
+ const key = caKey.trim();
610
+ const lines = existing.split('\n');
611
+ const removed = [], removedAt = [];
612
+ lines.forEach((l, i) => {
613
+ if (isSupersededAnchor(l, hostPattern, key)) {
614
+ removed.push(l);
615
+ removedAt.push(i);
616
+ }
617
+ });
618
+ const kept = lines.filter((l) => !isSupersededAnchor(l, hostPattern, key)).join('\n');
619
+ const base = kept === '' ? '' : kept.endsWith('\n') ? kept : kept + '\n';
620
+ const line = renderCertAuthority(hostPattern, key);
621
+ return { next: base + line, line: line.trimEnd(), removed, removedAt };
622
+ }
623
+ /**
624
+ * Undo a plan against known_hosts AS IT STANDS NOW, not by restoring a snapshot.
625
+ *
626
+ * `ssh` appends host keys to this file without taking any lock and cannot be
627
+ * made to take one, so writing back the bytes we read would discard whatever
628
+ * landed in between. Removing exactly the line we added and putting back
629
+ * exactly the lines we retired touches nothing else.
630
+ */
631
+ export function revertCertAuthority(current, plan) {
632
+ const kept = current.split('\n').filter((l) => l.trimEnd() !== plan.line);
633
+ // Only the ONE trailing blank the split leaves behind a final newline. A
634
+ // blank line before that is the user's -- trailing blanks included, which a
635
+ // loop popping every empty tail line was deleting on the failure path.
636
+ if (kept.length > 0 && kept[kept.length - 1] === '')
637
+ kept.pop();
638
+ // Only the anchors that are genuinely gone: a concurrent install may already
639
+ // have re-added one, and a duplicate anchor is not a failure mode. Each goes
640
+ // back at the index it was retired from, in ascending order, so a file
641
+ // nothing else touched comes back byte for byte -- and a file `ssh` appended
642
+ // to meanwhile comes back with the user's lines in their original order and
643
+ // the new ones after. The index is clamped, because the file may be shorter
644
+ // than it was.
645
+ plan.removed.forEach((l, i) => {
646
+ if (kept.some((k) => k.trimEnd() === l.trimEnd()))
647
+ return;
648
+ kept.splice(Math.min(plan.removedAt[i] ?? kept.length, kept.length), 0, l);
649
+ });
650
+ return kept.length === 0 ? '' : kept.join('\n') + '\n';
651
+ }
652
+ /** A known_hosts line this CLI wrote: `@cert-authority <pattern> <type> <blob>`
653
+ * followed by CA_MARKER as the WHOLE comment. The marker is matched as the
654
+ * exact trailing fields, not as a substring: a user's own anchor whose
655
+ * comment happens to mention us is theirs, and rotation must not retire it. */
656
+ export function isOurAnchor(line) {
657
+ const f = line.trim().split(/\s+/);
658
+ return f[0] === '@cert-authority' && f.length === 4 + CA_MARKER_FIELDS && f.slice(4).join(' ') === CA_MARKER;
659
+ }
660
+ const CA_MARKER_FIELDS = CA_MARKER.split(' ').length;
661
+ function isSupersededAnchor(line, hostPattern, key) {
662
+ if (!isOurAnchor(line))
663
+ return false;
664
+ // Compared FIELD BY FIELD, never with `includes`. A base64 key is an
665
+ // unanchored substring of any longer key sharing its prefix, so a substring
666
+ // test would delete a DIFFERENT region's anchor that happened to extend ours
667
+ // -- and a deleted anchor is not a visible failure, it is a host-key prompt
668
+ // on every connection to a region that used to be trusted.
669
+ const [, pattern = '', keyType, keyBlob] = line.trim().split(/\s+/);
670
+ // Rotation: the platform issued a new CA for a pattern we already anchor.
671
+ if (pattern === hostPattern)
672
+ return true;
673
+ const [wantType, wantBlob] = key.split(/\s+/);
674
+ if (keyType !== wantType || keyBlob !== wantBlob)
675
+ return false;
676
+ // Same key, DIFFERENT pattern, and which of the two things that is decides
677
+ // whether the old line may go. One CA signs every region, so "same key" alone
678
+ // proves nothing: outside CA_WIDENABLE_SUFFIXES each region gets its own exact
679
+ // anchor, and CA_WIDENABLE_SUFFIXES itself holds two gateway domains. Treating
680
+ // every same-key line as the old position of THIS anchor deleted an anchor
681
+ // another alias still depends on -- again a silent host-key prompt.
682
+ //
683
+ // What separates the two is containment. hostPatternFor derives the pattern
684
+ // from the host, so the only pattern change a single anchor can make on its
685
+ // own is over the region label: widening when the suffix becomes widenable,
686
+ // narrowing when it stops being. Either way one pattern covers the other, and
687
+ // dropping the covered line loses no trust the new line does not restore.
688
+ // Patterns that cover nothing of each other are different deployments, and
689
+ // both are kept.
690
+ return caPatternCovers(hostPattern, pattern) || caPatternCovers(pattern, hostPattern);
691
+ }
692
+ /** Whether every host matching `inner` also matches `outer`.
693
+ *
694
+ * Both patterns come from hostPatternFor, so each is either an exact hostname
695
+ * or a single `*` standing for one label -- which makes containment a
696
+ * label-by-label comparison rather than a question about pattern algebra. */
697
+ function caPatternCovers(outer, inner) {
698
+ if (outer === inner)
699
+ return true;
700
+ const o = outer.split('.'), i = inner.split('.');
701
+ // `*` never spans a dot in a known_hosts pattern, so a wider pattern has
702
+ // exactly as many labels as what it covers.
703
+ if (o.length !== i.length)
704
+ return false;
705
+ const star = o.indexOf('*');
706
+ if (star === -1)
707
+ return false;
708
+ return o.every((label, n) => n === star || label === i[n]);
709
+ }
710
+ //# sourceMappingURL=ssh-config.js.map