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.
@@ -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
+ }