dsh-server-connector 0.2.3 → 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.
- package/CHANGELOG.md +72 -0
- package/LICENSE +0 -0
- package/README.md +49 -7
- package/cordis.patch.yml +6 -6
- package/icon.svg +15 -15
- package/lib/audit.js +469 -455
- package/lib/client.js +2389 -1728
- package/lib/config.js +89 -0
- package/lib/index.js +316 -266
- package/lib/known-hosts.js +377 -377
- package/lib/pending.js +766 -753
- package/lib/policy.js +590 -590
- package/lib/pool.js +1496 -1464
- package/lib/registry.js +968 -968
- package/lib/routes.js +14 -13
- package/lib/tools.js +1367 -1357
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +105 -105
package/lib/known-hosts.js
CHANGED
|
@@ -1,377 +1,377 @@
|
|
|
1
|
-
// dsh-server-connector — host key fingerprints (design doc §3.2, §6.4, §7.4).
|
|
2
|
-
//
|
|
3
|
-
// The store remembers, per server id, the fingerprint the pool accepted on the
|
|
4
|
-
// first successful handshake:
|
|
5
|
-
//
|
|
6
|
-
// $DSH_HOME/server-connector/known_hosts.json
|
|
7
|
-
// { "srv-01": { "hash": "<sha256 base64>", "firstSeen": "...", "algorithm": "ssh-ed25519" } }
|
|
8
|
-
//
|
|
9
|
-
// The document is flat — one key per server id, no version wrapper — exactly as
|
|
10
|
-
// doc §3.2 draws it, so a person can inspect and edit it by hand (the plan's
|
|
11
|
-
// recovery path for a changed fingerprint is "confirm, then trust the new one").
|
|
12
|
-
//
|
|
13
|
-
// Guarantees, in the same spirit as the registry (§3.1):
|
|
14
|
-
// - a missing or empty file is an empty store; a read never creates the file;
|
|
15
|
-
// - a malformed document is reported (`INVALID_KNOWN_HOSTS`), never rewritten;
|
|
16
|
-
// - every mutation is an atomic replace (temp file in the same directory +
|
|
17
|
-
// rename) and is serialised per file across instances of this process;
|
|
18
|
-
// - `firstSeen` survives re-recording the same fingerprint, so it keeps meaning
|
|
19
|
-
// "when this host was first trusted".
|
|
20
|
-
//
|
|
21
|
-
// This module knows nothing about SSH connections: `lib/pool.js` owns the
|
|
22
|
-
// verification decision (§7.4); the store only persists its outcome.
|
|
23
|
-
|
|
24
|
-
import { createHash, randomBytes } from 'node:crypto'
|
|
25
|
-
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
26
|
-
import { dirname, join, resolve } from 'node:path'
|
|
27
|
-
|
|
28
|
-
import { connectorDirectory } from './registry.js'
|
|
29
|
-
|
|
30
|
-
/** File name of the fingerprint store. */
|
|
31
|
-
export const KNOWN_HOSTS_FILE_NAME = 'known_hosts.json'
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* One write promise per store file, so every KnownHosts instance in this
|
|
35
|
-
* process serialises its mutations against the same document (the registry
|
|
36
|
-
* uses the same pattern for servers.json).
|
|
37
|
-
* @type {Map<string, Promise<void>>}
|
|
38
|
-
*/
|
|
39
|
-
const MUTATION_QUEUES = new Map()
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* One stored fingerprint, exactly as persisted (doc §3.2).
|
|
43
|
-
* @typedef {object} HostKeyRecord
|
|
44
|
-
* @property {string} hash - SHA-256 of the host public key blob, base64 (the
|
|
45
|
-
* `SHA256:`-style digest ssh(1) prints, minus its prefix).
|
|
46
|
-
* @property {string} firstSeen - ISO-8601 timestamp of first acceptance.
|
|
47
|
-
* @property {string} algorithm - key type from the blob, e.g. `ssh-ed25519`.
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Machine-readable fingerprint-store failure. `INVALID_HOST_KEY` marks bytes
|
|
52
|
-
* that cannot be an SSH host key; `INVALID_KNOWN_HOSTS` marks a bad call or a
|
|
53
|
-
* malformed document on disk.
|
|
54
|
-
* @typedef {'INVALID_KNOWN_HOSTS'|'INVALID_HOST_KEY'} KnownHostsErrorCode
|
|
55
|
-
*/
|
|
56
|
-
export class KnownHostsError extends Error {
|
|
57
|
-
/**
|
|
58
|
-
* @param {KnownHostsErrorCode} code
|
|
59
|
-
* @param {string} message
|
|
60
|
-
*/
|
|
61
|
-
constructor(code, message) {
|
|
62
|
-
super(message)
|
|
63
|
-
this.name = 'KnownHostsError'
|
|
64
|
-
/** @type {KnownHostsErrorCode} */
|
|
65
|
-
this.code = code
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Default location of `known_hosts.json` (doc §3.5).
|
|
71
|
-
* @param {NodeJS.ProcessEnv} [env]
|
|
72
|
-
* @returns {string}
|
|
73
|
-
*/
|
|
74
|
-
export function defaultKnownHostsFile(env = process.env) {
|
|
75
|
-
return join(connectorDirectory(env), KNOWN_HOSTS_FILE_NAME)
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* @param {unknown} value
|
|
80
|
-
* @returns {value is Record<string, unknown>}
|
|
81
|
-
*/
|
|
82
|
-
function isPlainObject(value) {
|
|
83
|
-
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* @param {unknown} error
|
|
88
|
-
* @param {string} code
|
|
89
|
-
* @returns {boolean}
|
|
90
|
-
*/
|
|
91
|
-
function isErrno(error, code) {
|
|
92
|
-
return isPlainObject(error) && error.code === code
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Compute the stored fingerprint of a host public key.
|
|
97
|
-
*
|
|
98
|
-
* `ssh2`'s `hostVerifier` receives the raw SSH wire-format key blob (unless a
|
|
99
|
-
* `hostHash` option is set), so the digest is computed here: SHA-256 over the
|
|
100
|
-
* whole blob, base64-encoded, which is the value modern OpenSSH shows as
|
|
101
|
-
* `SHA256:<base64>`. The leading wire string of the blob is the key algorithm
|
|
102
|
-
* name (`ssh-ed25519`, `rsa-sha2-256`, …); it is kept for the human-readable
|
|
103
|
-
* panel row (doc §8.2) and to make a fingerprint file readable without a
|
|
104
|
-
* decoder.
|
|
105
|
-
* @param {Buffer|Uint8Array|string} hostKey - the host public key blob.
|
|
106
|
-
* @returns {{hash: string, algorithm: string}}
|
|
107
|
-
* @throws {KnownHostsError} `INVALID_HOST_KEY` when the bytes cannot be a key.
|
|
108
|
-
*/
|
|
109
|
-
export function fingerprintHostKey(hostKey) {
|
|
110
|
-
const blob = Buffer.isBuffer(hostKey) ? hostKey : Buffer.from(hostKey ?? [])
|
|
111
|
-
if (blob.length < 5) {
|
|
112
|
-
throw new KnownHostsError('INVALID_HOST_KEY', 'host public key is too short to be an SSH public key blob.')
|
|
113
|
-
}
|
|
114
|
-
const algorithmLength = blob.readUInt32BE(0)
|
|
115
|
-
if (algorithmLength < 1 || algorithmLength > blob.length - 4) {
|
|
116
|
-
throw new KnownHostsError('INVALID_HOST_KEY', 'host public key blob has a malformed algorithm-name field.')
|
|
117
|
-
}
|
|
118
|
-
const algorithm = blob.subarray(4, 4 + algorithmLength).toString('latin1')
|
|
119
|
-
if (!/^[A-Za-z0-9@._+-]+$/.test(algorithm)) {
|
|
120
|
-
throw new KnownHostsError('INVALID_HOST_KEY', `host public key blob names an implausible algorithm: ${algorithm}`)
|
|
121
|
-
}
|
|
122
|
-
return { hash: createHash('sha256').update(blob).digest('base64'), algorithm }
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* @param {unknown} value
|
|
127
|
-
* @param {string} field
|
|
128
|
-
* @returns {string}
|
|
129
|
-
*/
|
|
130
|
-
function requiredString(value, field) {
|
|
131
|
-
if (typeof value !== 'string' || value.trim() === '') {
|
|
132
|
-
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${field} must be a non-empty string.`)
|
|
133
|
-
}
|
|
134
|
-
return value.trim()
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* @param {unknown} value
|
|
139
|
-
* @param {string} field
|
|
140
|
-
* @returns {string}
|
|
141
|
-
*/
|
|
142
|
-
function requiredTimestamp(value, field) {
|
|
143
|
-
const text = requiredString(value, field)
|
|
144
|
-
if (Number.isNaN(new Date(text).getTime())) {
|
|
145
|
-
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${field} must be an ISO-8601 timestamp.`)
|
|
146
|
-
}
|
|
147
|
-
return new Date(text).toISOString()
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Validate one fingerprint digest. Accepting anything else would let a
|
|
152
|
-
* hand-edited file or a caller store a value the comparison can never match,
|
|
153
|
-
* which reads as "fingerprint changed" forever.
|
|
154
|
-
*
|
|
155
|
-
* The digest is the base64 SHA-256 of the host key blob, i.e. the `SHA256:…`
|
|
156
|
-
* value OpenSSH prints once the prefix is dropped. A 32-byte digest encodes to
|
|
157
|
-
* 44 characters including one `=` pad.
|
|
158
|
-
* @param {unknown} value
|
|
159
|
-
* @param {string} field
|
|
160
|
-
* @returns {string}
|
|
161
|
-
*/
|
|
162
|
-
function normalizeHash(value, field) {
|
|
163
|
-
const hash = requiredString(value, field)
|
|
164
|
-
if (!/^[A-Za-z0-9+/]{43}=$/.test(hash)) {
|
|
165
|
-
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${field} must be a base64 SHA-256 digest (44 characters).`)
|
|
166
|
-
}
|
|
167
|
-
return hash
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Validate one persisted record. Anything that is not exactly the §3.2 shape is
|
|
172
|
-
* rejected instead of being coerced, so a hand-edited file cannot silently
|
|
173
|
-
* weaken a comparison.
|
|
174
|
-
* @param {unknown} value
|
|
175
|
-
* @param {string} serverId
|
|
176
|
-
* @returns {HostKeyRecord}
|
|
177
|
-
*/
|
|
178
|
-
function normalizeRecord(value, serverId) {
|
|
179
|
-
if (!isPlainObject(value)) {
|
|
180
|
-
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `known_hosts entry "${serverId}" must be an object.`)
|
|
181
|
-
}
|
|
182
|
-
for (const key of Object.keys(value)) {
|
|
183
|
-
if (key !== 'hash' && key !== 'firstSeen' && key !== 'algorithm') {
|
|
184
|
-
throw new KnownHostsError(
|
|
185
|
-
'INVALID_KNOWN_HOSTS',
|
|
186
|
-
`known_hosts entry "${serverId}" has unknown field "${key}"; allowed: hash, firstSeen, algorithm.`,
|
|
187
|
-
)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
const hash = normalizeHash(value.hash, `known_hosts["${serverId}"].hash`)
|
|
191
|
-
return {
|
|
192
|
-
hash,
|
|
193
|
-
firstSeen: requiredTimestamp(value.firstSeen, `known_hosts["${serverId}"].firstSeen`),
|
|
194
|
-
algorithm: requiredString(value.algorithm, `known_hosts["${serverId}"].algorithm`),
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* The fingerprint store. One instance per file; instances on the same file share
|
|
200
|
-
* the mutation queue.
|
|
201
|
-
*/
|
|
202
|
-
export class KnownHosts {
|
|
203
|
-
/** @type {string} */
|
|
204
|
-
#file
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* @param {{file?: string, env?: NodeJS.ProcessEnv}} [options] - `file` exists
|
|
208
|
-
* for tests and for an embedding host that keeps the data elsewhere;
|
|
209
|
-
* production code passes nothing and gets `$DSH_HOME/server-connector/known_hosts.json`.
|
|
210
|
-
*/
|
|
211
|
-
constructor(options = {}) {
|
|
212
|
-
this.#file = options.file ?? defaultKnownHostsFile(options.env)
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Absolute path of the backing document. */
|
|
216
|
-
get file() {
|
|
217
|
-
return this.#file
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Every stored fingerprint, newest call reading the file afresh.
|
|
222
|
-
* @returns {Promise<Array<{serverId: string} & HostKeyRecord>>}
|
|
223
|
-
*/
|
|
224
|
-
async list() {
|
|
225
|
-
const document = await this.#load()
|
|
226
|
-
return Object.entries(document)
|
|
227
|
-
.map(([serverId, record]) => ({ serverId, ...record }))
|
|
228
|
-
.sort((left, right) => left.serverId.localeCompare(right.serverId))
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* The fingerprint remembered for one server, or `null` when the host has
|
|
233
|
-
* never been accepted (doc §7.4's `knownHosts.get(serverId)`).
|
|
234
|
-
* @param {string} serverId
|
|
235
|
-
* @returns {Promise<HostKeyRecord|null>}
|
|
236
|
-
*/
|
|
237
|
-
async get(serverId) {
|
|
238
|
-
const id = requiredString(serverId, 'serverId')
|
|
239
|
-
const document = await this.#load()
|
|
240
|
-
const record = document[id]
|
|
241
|
-
return record === undefined ? null : { ...record }
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Record a fingerprint. Re-recording the same hash keeps the original
|
|
246
|
-
* `firstSeen`; a different hash is a new trust decision and starts a new
|
|
247
|
-
* record (doc §6.4: a changed fingerprint is rejected until a person trusts it
|
|
248
|
-
* again).
|
|
249
|
-
* @param {string} serverId
|
|
250
|
-
* @param {{hash: string, algorithm: string, firstSeen?: string}} fingerprint
|
|
251
|
-
* @returns {Promise<HostKeyRecord>}
|
|
252
|
-
*/
|
|
253
|
-
async set(serverId, fingerprint) {
|
|
254
|
-
const id = requiredString(serverId, 'serverId')
|
|
255
|
-
if (!isPlainObject(fingerprint)) {
|
|
256
|
-
throw new KnownHostsError('INVALID_KNOWN_HOSTS', 'fingerprint must be an object with hash and algorithm.')
|
|
257
|
-
}
|
|
258
|
-
const hash = normalizeHash(fingerprint.hash, 'fingerprint.hash')
|
|
259
|
-
const algorithm = requiredString(fingerprint.algorithm, 'algorithm')
|
|
260
|
-
const now = new Date().toISOString()
|
|
261
|
-
return this.#mutate((document) => {
|
|
262
|
-
const existing = document[id]
|
|
263
|
-
const firstSeen =
|
|
264
|
-
existing !== undefined && existing.hash === hash
|
|
265
|
-
? existing.firstSeen
|
|
266
|
-
: fingerprint.firstSeen === undefined
|
|
267
|
-
? now
|
|
268
|
-
: requiredTimestamp(fingerprint.firstSeen, 'firstSeen')
|
|
269
|
-
/** @type {HostKeyRecord} */
|
|
270
|
-
const record = { hash, firstSeen, algorithm }
|
|
271
|
-
document[id] = record
|
|
272
|
-
return { ...record }
|
|
273
|
-
})
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Forget one server's fingerprint (used when a server is deleted).
|
|
278
|
-
* @param {string} serverId
|
|
279
|
-
* @returns {Promise<boolean>} whether a record existed.
|
|
280
|
-
*/
|
|
281
|
-
async remove(serverId) {
|
|
282
|
-
const id = requiredString(serverId, 'serverId')
|
|
283
|
-
return this.#mutate((document) => {
|
|
284
|
-
if (!(id in document)) return false
|
|
285
|
-
delete document[id]
|
|
286
|
-
return true
|
|
287
|
-
})
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Read and validate the whole document.
|
|
292
|
-
* @returns {Promise<Record<string, HostKeyRecord>>}
|
|
293
|
-
*/
|
|
294
|
-
async #load() {
|
|
295
|
-
/** @type {string} */
|
|
296
|
-
let text
|
|
297
|
-
try {
|
|
298
|
-
text = await readFile(this.#file, 'utf8')
|
|
299
|
-
} catch (error) {
|
|
300
|
-
if (isErrno(error, 'ENOENT')) return {}
|
|
301
|
-
throw error
|
|
302
|
-
}
|
|
303
|
-
if (text.trim() === '') return {}
|
|
304
|
-
|
|
305
|
-
/** @type {unknown} */
|
|
306
|
-
let parsed
|
|
307
|
-
try {
|
|
308
|
-
parsed = JSON.parse(text)
|
|
309
|
-
} catch {
|
|
310
|
-
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${this.#file} is not valid JSON; fix or remove it.`)
|
|
311
|
-
}
|
|
312
|
-
if (!isPlainObject(parsed)) {
|
|
313
|
-
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${this.#file} must contain a JSON object of serverId → record.`)
|
|
314
|
-
}
|
|
315
|
-
/** @type {Record<string, HostKeyRecord>} */
|
|
316
|
-
const document = {}
|
|
317
|
-
for (const [serverId, value] of Object.entries(parsed)) {
|
|
318
|
-
if (serverId.trim() === '') {
|
|
319
|
-
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${this.#file} has an empty server id.`)
|
|
320
|
-
}
|
|
321
|
-
document[serverId] = normalizeRecord(value, serverId)
|
|
322
|
-
}
|
|
323
|
-
return document
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
/**
|
|
327
|
-
* Commit the document atomically: write a sibling temp file, then rename it
|
|
328
|
-
* over the store, so a reader sees either the old or the new complete file.
|
|
329
|
-
* @param {Record<string, HostKeyRecord>} document
|
|
330
|
-
* @returns {Promise<void>}
|
|
331
|
-
*/
|
|
332
|
-
async #save(document) {
|
|
333
|
-
await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
|
|
334
|
-
const temporary = `${this.#file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
335
|
-
// Keep a stable, readable ordering (doc §3.2) instead of insertion order.
|
|
336
|
-
/** @type {Record<string, HostKeyRecord>} */
|
|
337
|
-
const ordered = {}
|
|
338
|
-
for (const serverId of Object.keys(document).sort()) {
|
|
339
|
-
const record = document[serverId]
|
|
340
|
-
if (record !== undefined) ordered[serverId] = record
|
|
341
|
-
}
|
|
342
|
-
try {
|
|
343
|
-
await writeFile(temporary, `${JSON.stringify(ordered, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
344
|
-
await rename(temporary, this.#file)
|
|
345
|
-
} catch (error) {
|
|
346
|
-
await unlink(temporary).catch(() => {})
|
|
347
|
-
throw error
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* Run one read-modify-write after every earlier mutation of the same file
|
|
353
|
-
* finished, in this process and across instances.
|
|
354
|
-
* @template T
|
|
355
|
-
* @param {(document: Record<string, HostKeyRecord>) => T} mutate
|
|
356
|
-
* @returns {Promise<T>}
|
|
357
|
-
*/
|
|
358
|
-
async #mutate(mutate) {
|
|
359
|
-
const key = resolve(this.#file)
|
|
360
|
-
const previous = MUTATION_QUEUES.get(key) ?? Promise.resolve()
|
|
361
|
-
const operation = previous.then(async () => {
|
|
362
|
-
const document = await this.#load()
|
|
363
|
-
const result = mutate(document)
|
|
364
|
-
await this.#save(document)
|
|
365
|
-
return result
|
|
366
|
-
})
|
|
367
|
-
const settled = operation.then(
|
|
368
|
-
() => undefined,
|
|
369
|
-
() => undefined,
|
|
370
|
-
)
|
|
371
|
-
MUTATION_QUEUES.set(key, settled)
|
|
372
|
-
void settled.then(() => {
|
|
373
|
-
if (MUTATION_QUEUES.get(key) === settled) MUTATION_QUEUES.delete(key)
|
|
374
|
-
})
|
|
375
|
-
return operation
|
|
376
|
-
}
|
|
377
|
-
}
|
|
1
|
+
// dsh-server-connector — host key fingerprints (design doc §3.2, §6.4, §7.4).
|
|
2
|
+
//
|
|
3
|
+
// The store remembers, per server id, the fingerprint the pool accepted on the
|
|
4
|
+
// first successful handshake:
|
|
5
|
+
//
|
|
6
|
+
// $DSH_HOME/server-connector/known_hosts.json
|
|
7
|
+
// { "srv-01": { "hash": "<sha256 base64>", "firstSeen": "...", "algorithm": "ssh-ed25519" } }
|
|
8
|
+
//
|
|
9
|
+
// The document is flat — one key per server id, no version wrapper — exactly as
|
|
10
|
+
// doc §3.2 draws it, so a person can inspect and edit it by hand (the plan's
|
|
11
|
+
// recovery path for a changed fingerprint is "confirm, then trust the new one").
|
|
12
|
+
//
|
|
13
|
+
// Guarantees, in the same spirit as the registry (§3.1):
|
|
14
|
+
// - a missing or empty file is an empty store; a read never creates the file;
|
|
15
|
+
// - a malformed document is reported (`INVALID_KNOWN_HOSTS`), never rewritten;
|
|
16
|
+
// - every mutation is an atomic replace (temp file in the same directory +
|
|
17
|
+
// rename) and is serialised per file across instances of this process;
|
|
18
|
+
// - `firstSeen` survives re-recording the same fingerprint, so it keeps meaning
|
|
19
|
+
// "when this host was first trusted".
|
|
20
|
+
//
|
|
21
|
+
// This module knows nothing about SSH connections: `lib/pool.js` owns the
|
|
22
|
+
// verification decision (§7.4); the store only persists its outcome.
|
|
23
|
+
|
|
24
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
25
|
+
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
|
|
26
|
+
import { dirname, join, resolve } from 'node:path'
|
|
27
|
+
|
|
28
|
+
import { connectorDirectory } from './registry.js'
|
|
29
|
+
|
|
30
|
+
/** File name of the fingerprint store. */
|
|
31
|
+
export const KNOWN_HOSTS_FILE_NAME = 'known_hosts.json'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One write promise per store file, so every KnownHosts instance in this
|
|
35
|
+
* process serialises its mutations against the same document (the registry
|
|
36
|
+
* uses the same pattern for servers.json).
|
|
37
|
+
* @type {Map<string, Promise<void>>}
|
|
38
|
+
*/
|
|
39
|
+
const MUTATION_QUEUES = new Map()
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One stored fingerprint, exactly as persisted (doc §3.2).
|
|
43
|
+
* @typedef {object} HostKeyRecord
|
|
44
|
+
* @property {string} hash - SHA-256 of the host public key blob, base64 (the
|
|
45
|
+
* `SHA256:`-style digest ssh(1) prints, minus its prefix).
|
|
46
|
+
* @property {string} firstSeen - ISO-8601 timestamp of first acceptance.
|
|
47
|
+
* @property {string} algorithm - key type from the blob, e.g. `ssh-ed25519`.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Machine-readable fingerprint-store failure. `INVALID_HOST_KEY` marks bytes
|
|
52
|
+
* that cannot be an SSH host key; `INVALID_KNOWN_HOSTS` marks a bad call or a
|
|
53
|
+
* malformed document on disk.
|
|
54
|
+
* @typedef {'INVALID_KNOWN_HOSTS'|'INVALID_HOST_KEY'} KnownHostsErrorCode
|
|
55
|
+
*/
|
|
56
|
+
export class KnownHostsError extends Error {
|
|
57
|
+
/**
|
|
58
|
+
* @param {KnownHostsErrorCode} code
|
|
59
|
+
* @param {string} message
|
|
60
|
+
*/
|
|
61
|
+
constructor(code, message) {
|
|
62
|
+
super(message)
|
|
63
|
+
this.name = 'KnownHostsError'
|
|
64
|
+
/** @type {KnownHostsErrorCode} */
|
|
65
|
+
this.code = code
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Default location of `known_hosts.json` (doc §3.5).
|
|
71
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
72
|
+
* @returns {string}
|
|
73
|
+
*/
|
|
74
|
+
export function defaultKnownHostsFile(env = process.env) {
|
|
75
|
+
return join(connectorDirectory(env), KNOWN_HOSTS_FILE_NAME)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {unknown} value
|
|
80
|
+
* @returns {value is Record<string, unknown>}
|
|
81
|
+
*/
|
|
82
|
+
function isPlainObject(value) {
|
|
83
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {unknown} error
|
|
88
|
+
* @param {string} code
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
function isErrno(error, code) {
|
|
92
|
+
return isPlainObject(error) && error.code === code
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Compute the stored fingerprint of a host public key.
|
|
97
|
+
*
|
|
98
|
+
* `ssh2`'s `hostVerifier` receives the raw SSH wire-format key blob (unless a
|
|
99
|
+
* `hostHash` option is set), so the digest is computed here: SHA-256 over the
|
|
100
|
+
* whole blob, base64-encoded, which is the value modern OpenSSH shows as
|
|
101
|
+
* `SHA256:<base64>`. The leading wire string of the blob is the key algorithm
|
|
102
|
+
* name (`ssh-ed25519`, `rsa-sha2-256`, …); it is kept for the human-readable
|
|
103
|
+
* panel row (doc §8.2) and to make a fingerprint file readable without a
|
|
104
|
+
* decoder.
|
|
105
|
+
* @param {Buffer|Uint8Array|string} hostKey - the host public key blob.
|
|
106
|
+
* @returns {{hash: string, algorithm: string}}
|
|
107
|
+
* @throws {KnownHostsError} `INVALID_HOST_KEY` when the bytes cannot be a key.
|
|
108
|
+
*/
|
|
109
|
+
export function fingerprintHostKey(hostKey) {
|
|
110
|
+
const blob = Buffer.isBuffer(hostKey) ? hostKey : Buffer.from(hostKey ?? [])
|
|
111
|
+
if (blob.length < 5) {
|
|
112
|
+
throw new KnownHostsError('INVALID_HOST_KEY', 'host public key is too short to be an SSH public key blob.')
|
|
113
|
+
}
|
|
114
|
+
const algorithmLength = blob.readUInt32BE(0)
|
|
115
|
+
if (algorithmLength < 1 || algorithmLength > blob.length - 4) {
|
|
116
|
+
throw new KnownHostsError('INVALID_HOST_KEY', 'host public key blob has a malformed algorithm-name field.')
|
|
117
|
+
}
|
|
118
|
+
const algorithm = blob.subarray(4, 4 + algorithmLength).toString('latin1')
|
|
119
|
+
if (!/^[A-Za-z0-9@._+-]+$/.test(algorithm)) {
|
|
120
|
+
throw new KnownHostsError('INVALID_HOST_KEY', `host public key blob names an implausible algorithm: ${algorithm}`)
|
|
121
|
+
}
|
|
122
|
+
return { hash: createHash('sha256').update(blob).digest('base64'), algorithm }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {unknown} value
|
|
127
|
+
* @param {string} field
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function requiredString(value, field) {
|
|
131
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
132
|
+
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${field} must be a non-empty string.`)
|
|
133
|
+
}
|
|
134
|
+
return value.trim()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {unknown} value
|
|
139
|
+
* @param {string} field
|
|
140
|
+
* @returns {string}
|
|
141
|
+
*/
|
|
142
|
+
function requiredTimestamp(value, field) {
|
|
143
|
+
const text = requiredString(value, field)
|
|
144
|
+
if (Number.isNaN(new Date(text).getTime())) {
|
|
145
|
+
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${field} must be an ISO-8601 timestamp.`)
|
|
146
|
+
}
|
|
147
|
+
return new Date(text).toISOString()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validate one fingerprint digest. Accepting anything else would let a
|
|
152
|
+
* hand-edited file or a caller store a value the comparison can never match,
|
|
153
|
+
* which reads as "fingerprint changed" forever.
|
|
154
|
+
*
|
|
155
|
+
* The digest is the base64 SHA-256 of the host key blob, i.e. the `SHA256:…`
|
|
156
|
+
* value OpenSSH prints once the prefix is dropped. A 32-byte digest encodes to
|
|
157
|
+
* 44 characters including one `=` pad.
|
|
158
|
+
* @param {unknown} value
|
|
159
|
+
* @param {string} field
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
function normalizeHash(value, field) {
|
|
163
|
+
const hash = requiredString(value, field)
|
|
164
|
+
if (!/^[A-Za-z0-9+/]{43}=$/.test(hash)) {
|
|
165
|
+
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${field} must be a base64 SHA-256 digest (44 characters).`)
|
|
166
|
+
}
|
|
167
|
+
return hash
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Validate one persisted record. Anything that is not exactly the §3.2 shape is
|
|
172
|
+
* rejected instead of being coerced, so a hand-edited file cannot silently
|
|
173
|
+
* weaken a comparison.
|
|
174
|
+
* @param {unknown} value
|
|
175
|
+
* @param {string} serverId
|
|
176
|
+
* @returns {HostKeyRecord}
|
|
177
|
+
*/
|
|
178
|
+
function normalizeRecord(value, serverId) {
|
|
179
|
+
if (!isPlainObject(value)) {
|
|
180
|
+
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `known_hosts entry "${serverId}" must be an object.`)
|
|
181
|
+
}
|
|
182
|
+
for (const key of Object.keys(value)) {
|
|
183
|
+
if (key !== 'hash' && key !== 'firstSeen' && key !== 'algorithm') {
|
|
184
|
+
throw new KnownHostsError(
|
|
185
|
+
'INVALID_KNOWN_HOSTS',
|
|
186
|
+
`known_hosts entry "${serverId}" has unknown field "${key}"; allowed: hash, firstSeen, algorithm.`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const hash = normalizeHash(value.hash, `known_hosts["${serverId}"].hash`)
|
|
191
|
+
return {
|
|
192
|
+
hash,
|
|
193
|
+
firstSeen: requiredTimestamp(value.firstSeen, `known_hosts["${serverId}"].firstSeen`),
|
|
194
|
+
algorithm: requiredString(value.algorithm, `known_hosts["${serverId}"].algorithm`),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The fingerprint store. One instance per file; instances on the same file share
|
|
200
|
+
* the mutation queue.
|
|
201
|
+
*/
|
|
202
|
+
export class KnownHosts {
|
|
203
|
+
/** @type {string} */
|
|
204
|
+
#file
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {{file?: string, env?: NodeJS.ProcessEnv}} [options] - `file` exists
|
|
208
|
+
* for tests and for an embedding host that keeps the data elsewhere;
|
|
209
|
+
* production code passes nothing and gets `$DSH_HOME/server-connector/known_hosts.json`.
|
|
210
|
+
*/
|
|
211
|
+
constructor(options = {}) {
|
|
212
|
+
this.#file = options.file ?? defaultKnownHostsFile(options.env)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Absolute path of the backing document. */
|
|
216
|
+
get file() {
|
|
217
|
+
return this.#file
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Every stored fingerprint, newest call reading the file afresh.
|
|
222
|
+
* @returns {Promise<Array<{serverId: string} & HostKeyRecord>>}
|
|
223
|
+
*/
|
|
224
|
+
async list() {
|
|
225
|
+
const document = await this.#load()
|
|
226
|
+
return Object.entries(document)
|
|
227
|
+
.map(([serverId, record]) => ({ serverId, ...record }))
|
|
228
|
+
.sort((left, right) => left.serverId.localeCompare(right.serverId))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The fingerprint remembered for one server, or `null` when the host has
|
|
233
|
+
* never been accepted (doc §7.4's `knownHosts.get(serverId)`).
|
|
234
|
+
* @param {string} serverId
|
|
235
|
+
* @returns {Promise<HostKeyRecord|null>}
|
|
236
|
+
*/
|
|
237
|
+
async get(serverId) {
|
|
238
|
+
const id = requiredString(serverId, 'serverId')
|
|
239
|
+
const document = await this.#load()
|
|
240
|
+
const record = document[id]
|
|
241
|
+
return record === undefined ? null : { ...record }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Record a fingerprint. Re-recording the same hash keeps the original
|
|
246
|
+
* `firstSeen`; a different hash is a new trust decision and starts a new
|
|
247
|
+
* record (doc §6.4: a changed fingerprint is rejected until a person trusts it
|
|
248
|
+
* again).
|
|
249
|
+
* @param {string} serverId
|
|
250
|
+
* @param {{hash: string, algorithm: string, firstSeen?: string}} fingerprint
|
|
251
|
+
* @returns {Promise<HostKeyRecord>}
|
|
252
|
+
*/
|
|
253
|
+
async set(serverId, fingerprint) {
|
|
254
|
+
const id = requiredString(serverId, 'serverId')
|
|
255
|
+
if (!isPlainObject(fingerprint)) {
|
|
256
|
+
throw new KnownHostsError('INVALID_KNOWN_HOSTS', 'fingerprint must be an object with hash and algorithm.')
|
|
257
|
+
}
|
|
258
|
+
const hash = normalizeHash(fingerprint.hash, 'fingerprint.hash')
|
|
259
|
+
const algorithm = requiredString(fingerprint.algorithm, 'algorithm')
|
|
260
|
+
const now = new Date().toISOString()
|
|
261
|
+
return this.#mutate((document) => {
|
|
262
|
+
const existing = document[id]
|
|
263
|
+
const firstSeen =
|
|
264
|
+
existing !== undefined && existing.hash === hash
|
|
265
|
+
? existing.firstSeen
|
|
266
|
+
: fingerprint.firstSeen === undefined
|
|
267
|
+
? now
|
|
268
|
+
: requiredTimestamp(fingerprint.firstSeen, 'firstSeen')
|
|
269
|
+
/** @type {HostKeyRecord} */
|
|
270
|
+
const record = { hash, firstSeen, algorithm }
|
|
271
|
+
document[id] = record
|
|
272
|
+
return { ...record }
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Forget one server's fingerprint (used when a server is deleted).
|
|
278
|
+
* @param {string} serverId
|
|
279
|
+
* @returns {Promise<boolean>} whether a record existed.
|
|
280
|
+
*/
|
|
281
|
+
async remove(serverId) {
|
|
282
|
+
const id = requiredString(serverId, 'serverId')
|
|
283
|
+
return this.#mutate((document) => {
|
|
284
|
+
if (!(id in document)) return false
|
|
285
|
+
delete document[id]
|
|
286
|
+
return true
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Read and validate the whole document.
|
|
292
|
+
* @returns {Promise<Record<string, HostKeyRecord>>}
|
|
293
|
+
*/
|
|
294
|
+
async #load() {
|
|
295
|
+
/** @type {string} */
|
|
296
|
+
let text
|
|
297
|
+
try {
|
|
298
|
+
text = await readFile(this.#file, 'utf8')
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (isErrno(error, 'ENOENT')) return {}
|
|
301
|
+
throw error
|
|
302
|
+
}
|
|
303
|
+
if (text.trim() === '') return {}
|
|
304
|
+
|
|
305
|
+
/** @type {unknown} */
|
|
306
|
+
let parsed
|
|
307
|
+
try {
|
|
308
|
+
parsed = JSON.parse(text)
|
|
309
|
+
} catch {
|
|
310
|
+
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${this.#file} is not valid JSON; fix or remove it.`)
|
|
311
|
+
}
|
|
312
|
+
if (!isPlainObject(parsed)) {
|
|
313
|
+
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${this.#file} must contain a JSON object of serverId → record.`)
|
|
314
|
+
}
|
|
315
|
+
/** @type {Record<string, HostKeyRecord>} */
|
|
316
|
+
const document = {}
|
|
317
|
+
for (const [serverId, value] of Object.entries(parsed)) {
|
|
318
|
+
if (serverId.trim() === '') {
|
|
319
|
+
throw new KnownHostsError('INVALID_KNOWN_HOSTS', `${this.#file} has an empty server id.`)
|
|
320
|
+
}
|
|
321
|
+
document[serverId] = normalizeRecord(value, serverId)
|
|
322
|
+
}
|
|
323
|
+
return document
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Commit the document atomically: write a sibling temp file, then rename it
|
|
328
|
+
* over the store, so a reader sees either the old or the new complete file.
|
|
329
|
+
* @param {Record<string, HostKeyRecord>} document
|
|
330
|
+
* @returns {Promise<void>}
|
|
331
|
+
*/
|
|
332
|
+
async #save(document) {
|
|
333
|
+
await mkdir(dirname(this.#file), { recursive: true, mode: 0o700 })
|
|
334
|
+
const temporary = `${this.#file}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
|
|
335
|
+
// Keep a stable, readable ordering (doc §3.2) instead of insertion order.
|
|
336
|
+
/** @type {Record<string, HostKeyRecord>} */
|
|
337
|
+
const ordered = {}
|
|
338
|
+
for (const serverId of Object.keys(document).sort()) {
|
|
339
|
+
const record = document[serverId]
|
|
340
|
+
if (record !== undefined) ordered[serverId] = record
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
await writeFile(temporary, `${JSON.stringify(ordered, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
344
|
+
await rename(temporary, this.#file)
|
|
345
|
+
} catch (error) {
|
|
346
|
+
await unlink(temporary).catch(() => {})
|
|
347
|
+
throw error
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Run one read-modify-write after every earlier mutation of the same file
|
|
353
|
+
* finished, in this process and across instances.
|
|
354
|
+
* @template T
|
|
355
|
+
* @param {(document: Record<string, HostKeyRecord>) => T} mutate
|
|
356
|
+
* @returns {Promise<T>}
|
|
357
|
+
*/
|
|
358
|
+
async #mutate(mutate) {
|
|
359
|
+
const key = resolve(this.#file)
|
|
360
|
+
const previous = MUTATION_QUEUES.get(key) ?? Promise.resolve()
|
|
361
|
+
const operation = previous.then(async () => {
|
|
362
|
+
const document = await this.#load()
|
|
363
|
+
const result = mutate(document)
|
|
364
|
+
await this.#save(document)
|
|
365
|
+
return result
|
|
366
|
+
})
|
|
367
|
+
const settled = operation.then(
|
|
368
|
+
() => undefined,
|
|
369
|
+
() => undefined,
|
|
370
|
+
)
|
|
371
|
+
MUTATION_QUEUES.set(key, settled)
|
|
372
|
+
void settled.then(() => {
|
|
373
|
+
if (MUTATION_QUEUES.get(key) === settled) MUTATION_QUEUES.delete(key)
|
|
374
|
+
})
|
|
375
|
+
return operation
|
|
376
|
+
}
|
|
377
|
+
}
|