insta 0.0.73 → 0.0.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.js +4 -1
- package/dist/api.js +11 -4
- package/dist/commands/agent-policy.js +119 -19
- package/dist/commands/auth.js +115 -7
- package/dist/commands/compute.js +1132 -2
- package/dist/commands/domain.js +3 -1
- package/dist/commands/env.js +1 -0
- package/dist/commands/ssh-config.js +710 -0
- package/dist/commands/upgrade.js +15 -2
- package/dist/config.js +1 -0
- package/dist/index.js +21 -3
- package/dist/util.js +99 -0
- package/package.json +1 -1
|
@@ -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
|