dsh-ssh-workbench 0.0.0-stage → 1.6.4

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +504 -0
  2. package/CONTRIBUTING.md +62 -0
  3. package/LICENSE +21 -0
  4. package/README.md +159 -2
  5. package/SECURITY.md +25 -0
  6. package/cordis.patch.yml +5 -0
  7. package/docs/architecture.md +124 -0
  8. package/docs/quickstart.md +94 -0
  9. package/lib/client.js +4977 -0
  10. package/lib/core/activity.js +83 -0
  11. package/lib/core/background.js +69 -0
  12. package/lib/core/broker.js +799 -0
  13. package/lib/core/dialect.js +28 -0
  14. package/lib/core/errors.js +138 -0
  15. package/lib/core/gate-pwsh.js +88 -0
  16. package/lib/core/gate.js +572 -0
  17. package/lib/core/guard.js +71 -0
  18. package/lib/core/known-hosts.js +74 -0
  19. package/lib/core/local-file-api.js +174 -0
  20. package/lib/core/localfs.js +381 -0
  21. package/lib/core/paths.js +104 -0
  22. package/lib/core/posix-quote.js +4 -0
  23. package/lib/core/prefs.js +38 -0
  24. package/lib/core/remote-bash-tool.js +321 -0
  25. package/lib/core/remote-bash.js +337 -0
  26. package/lib/core/secrets.js +96 -0
  27. package/lib/core/server-reference.js +299 -0
  28. package/lib/core/shell-adapters.js +48 -0
  29. package/lib/core/ssh.js +761 -0
  30. package/lib/core/store.js +83 -0
  31. package/lib/core/targets.js +250 -0
  32. package/lib/core/terminals.js +299 -0
  33. package/lib/core/win32-adapter.js +141 -0
  34. package/lib/index.js +450 -0
  35. package/lib/routes.js +733 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +88 -4
  39. package/vendor/NOTICE.md +50 -0
  40. package/vendor/XTERM-LICENSE +21 -0
  41. package/vendor/node_modules/asn1/LICENSE +19 -0
  42. package/vendor/node_modules/asn1/README.md +50 -0
  43. package/vendor/node_modules/asn1/lib/ber/errors.js +13 -0
  44. package/vendor/node_modules/asn1/lib/ber/index.js +27 -0
  45. package/vendor/node_modules/asn1/lib/ber/reader.js +262 -0
  46. package/vendor/node_modules/asn1/lib/ber/types.js +36 -0
  47. package/vendor/node_modules/asn1/lib/ber/writer.js +317 -0
  48. package/vendor/node_modules/asn1/lib/index.js +20 -0
  49. package/vendor/node_modules/asn1/package.json +31 -0
  50. package/vendor/node_modules/bcrypt-pbkdf/CONTRIBUTING.md +13 -0
  51. package/vendor/node_modules/bcrypt-pbkdf/LICENSE +66 -0
  52. package/vendor/node_modules/bcrypt-pbkdf/README.md +45 -0
  53. package/vendor/node_modules/bcrypt-pbkdf/index.js +556 -0
  54. package/vendor/node_modules/bcrypt-pbkdf/package.json +15 -0
  55. package/vendor/node_modules/safer-buffer/LICENSE +21 -0
  56. package/vendor/node_modules/safer-buffer/Readme.md +156 -0
  57. package/vendor/node_modules/safer-buffer/dangerous.js +58 -0
  58. package/vendor/node_modules/safer-buffer/package.json +34 -0
  59. package/vendor/node_modules/safer-buffer/safer.js +77 -0
  60. package/vendor/node_modules/tweetnacl/AUTHORS.md +28 -0
  61. package/vendor/node_modules/tweetnacl/LICENSE +24 -0
  62. package/vendor/node_modules/tweetnacl/README.md +459 -0
  63. package/vendor/node_modules/tweetnacl/nacl-fast.js +2388 -0
  64. package/vendor/node_modules/tweetnacl/nacl.js +1175 -0
  65. package/vendor/node_modules/tweetnacl/package.json +58 -0
  66. package/vendor/ssh2/LICENSE +19 -0
  67. package/vendor/ssh2/lib/Channel.js +295 -0
  68. package/vendor/ssh2/lib/agent.js +1123 -0
  69. package/vendor/ssh2/lib/client.js +2176 -0
  70. package/vendor/ssh2/lib/http-agents.js +84 -0
  71. package/vendor/ssh2/lib/index.js +44 -0
  72. package/vendor/ssh2/lib/keygen.js +582 -0
  73. package/vendor/ssh2/lib/protocol/Protocol.js +2136 -0
  74. package/vendor/ssh2/lib/protocol/SFTP.js +4052 -0
  75. package/vendor/ssh2/lib/protocol/constants.js +356 -0
  76. package/vendor/ssh2/lib/protocol/crypto/poly1305.js +43 -0
  77. package/vendor/ssh2/lib/protocol/crypto.js +1602 -0
  78. package/vendor/ssh2/lib/protocol/handlers.js +16 -0
  79. package/vendor/ssh2/lib/protocol/handlers.misc.js +1285 -0
  80. package/vendor/ssh2/lib/protocol/kex.js +1908 -0
  81. package/vendor/ssh2/lib/protocol/keyParser.js +1484 -0
  82. package/vendor/ssh2/lib/protocol/node-fs-compat.js +115 -0
  83. package/vendor/ssh2/lib/protocol/utils.js +356 -0
  84. package/vendor/ssh2/lib/protocol/zlib.js +255 -0
  85. package/vendor/ssh2/lib/server.js +1380 -0
  86. package/vendor/ssh2/lib/utils.js +336 -0
  87. package/vendor/ssh2/package.json +34 -0
  88. package/vendor/xterm.js +53 -0
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Route the native `bash` tool to a managed server.
3
+ *
4
+ * `tools/execute` is DSH's around-dispatch waterfall. This handler returns a
5
+ * replacement result for exactly one tool — the built-in `bash` — and only
6
+ * while the session has an active managed target; every other call falls
7
+ * through with `next()`. The replacement value is the native bash foreground
8
+ * value, so the registry validates it against the built-in output schema and
9
+ * renders it with the built-in terminal renderer: no shadow tool, no schema
10
+ * copy, no custom rendering.
11
+ */
12
+ import { describeResultOutcome } from './activity.js'
13
+ import { buildPosixBackgroundCommand, parseBackgroundStart, renderBackgroundStart } from './background.js'
14
+ import { effectiveDialect } from './dialect.js'
15
+ import { quotePosix } from './posix-quote.js'
16
+ import { findTarget } from './targets.js'
17
+
18
+ export const BASH_TOOL_NAME = 'bash'
19
+
20
+ const MIN_TIMEOUT_MS = 1000
21
+ const POSIX_PLATFORM = 'posix'
22
+
23
+ export { quotePosix }
24
+
25
+ /**
26
+ * Wrap a remote command with the remote `timeout` utility.
27
+ *
28
+ * A client-side timeout only closes the SSH channel; a process that ignores
29
+ * SIGHUP (nohup/background) keeps running on the server. Wrapping the command
30
+ * gives the remote side the same deadline: coreutils `timeout` runs the
31
+ * managed command in its own process group and signals the whole group, so
32
+ * when the client gives up the remote work stops too. `kill` adds `-k <n>`
33
+ * (KILL after TERM); `basic` sends TERM only; `none` (or an unknown
34
+ * capability) leaves the command untouched.
35
+ *
36
+ * The wrapper re-enters the remote login shell (`<shell> -c`) so shell
37
+ * semantics do not change. The shell path comes from the capability probe and
38
+ * is referenced as a quoted literal, so this also works on fish/csh (no POSIX
39
+ * `${SHELL:-...}` expansion). The command itself is single-quote escaped and
40
+ * therefore expanded exactly once, inside that shell.
41
+ *
42
+ * @param {string} command
43
+ * @param {number} timeoutMs
44
+ * @param {'kill'|'basic'|'none'|{mode?: string, shell?: string}} [capability]
45
+ * @param {{ killAfterMs?: number }} [options]
46
+ */
47
+ export function wrapRemoteTimeout(command, timeoutMs, capability = 'none', options = {}) {
48
+ const info = typeof capability === 'string' ? { mode: capability } : (capability ?? {})
49
+ const mode = info.mode === 'kill' || info.mode === 'basic' ? info.mode : 'none'
50
+ if (mode === 'none') return String(command ?? '')
51
+ const shell = typeof info.shell === 'string' && info.shell.startsWith('/') ? info.shell : '/bin/sh'
52
+ const killAfterMs = Number.isFinite(options.killAfterMs) ? options.killAfterMs : 5000
53
+ const killSeconds = Math.max(0, Math.ceil(killAfterMs / 1000))
54
+ const seconds = Math.max(1, Number(timeoutMs) / 1000)
55
+ const suffix = mode === 'kill' && killSeconds > 0 ? `-k ${killSeconds} ` : ''
56
+ return `timeout ${suffix}${seconds} ${quotePosix(shell)} -c ${quotePosix(command)}`
57
+ }
58
+
59
+ /**
60
+ * Turn the model-facing `workdir` into a shell prefix and the value's cwd label.
61
+ *
62
+ * The remote shell starts in the login home; absolute paths are used verbatim,
63
+ * `~`/`~/x` and relative paths are anchored at `$HOME`.
64
+ *
65
+ * @returns {{ command: string, cwd: string } | { error: string }}
66
+ */
67
+ export function applyWorkdir(command, workdir) {
68
+ const raw = typeof workdir === 'string' ? workdir.trim() : ''
69
+ const body = String(command ?? '')
70
+ if (!raw) return { command: body, cwd: '~' }
71
+ if (raw.includes('\n') || raw.includes('\r')) return { error: 'workdir must not contain newlines' }
72
+ if (raw.startsWith('~')) {
73
+ const rest = raw === '~' ? '' : raw.slice(1)
74
+ if (rest !== '' && !rest.startsWith('/')) return { error: 'workdir must be an absolute path, ~/path, or a relative path' }
75
+ const target = rest === '' ? '"$HOME"' : `"$HOME"${quotePosix(rest)}`
76
+ return { command: `cd -- ${target} && ${body}`, cwd: raw }
77
+ }
78
+ if (raw.startsWith('/')) {
79
+ return { command: `cd -- ${quotePosix(raw)} && ${body}`, cwd: raw }
80
+ }
81
+ return { command: `cd -- "$HOME"/${quotePosix(raw)} && ${body}`, cwd: `~/${raw}` }
82
+ }
83
+
84
+ /** Effective timeout for one call, clamped to the configured ceiling. */
85
+ export function resolveTimeoutMs(args, config) {
86
+ const ceiling = Number.isInteger(config?.maxCommandTimeoutMs) ? config.maxCommandTimeoutMs : 30 * 60 * 1000
87
+ const requested = Number.isInteger(args?.timeoutMs) ? args.timeoutMs : undefined
88
+ const fallback = Number.isInteger(config?.commandTimeoutMs) ? config.commandTimeoutMs : 120000
89
+ const value = requested ?? fallback
90
+ return Math.max(MIN_TIMEOUT_MS, Math.min(value, ceiling))
91
+ }
92
+
93
+ /**
94
+ * Map one runtime exec result onto the native bash foreground value.
95
+ * Non-zero exits are normal results; only "the command never ran" becomes an
96
+ * error result on the caller side.
97
+ */
98
+ export function toBashValue(result, { cwd, timeoutMs }) {
99
+ const stdout = typeof result?.stdout === 'string' ? result.stdout : ''
100
+ const stderr = typeof result?.stderr === 'string' ? result.stderr : ''
101
+ const exitCode = Number.isInteger(result?.exitCode) ? result.exitCode : null
102
+ const signal = typeof result?.signal === 'string' && result.signal.length > 0 ? result.signal : null
103
+ return {
104
+ kind: 'foreground',
105
+ cwd,
106
+ exitCode,
107
+ signal,
108
+ timedOut: result?.outcome === 'timeout',
109
+ aborted: result?.outcome === 'cancelled',
110
+ timeoutMs,
111
+ stdout: { text: stdout, truncated: result?.truncated?.stdout === true },
112
+ stderr: { text: stderr, truncated: result?.truncated?.stderr === true },
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Whether a runtime result means the command never reached the remote shell.
118
+ * Timeouts and aborts are real command outcomes (timedOut / aborted on the
119
+ * native value), not transport failures.
120
+ */
121
+ export function isTransportFailure(result) {
122
+ if (!result || typeof result !== 'object') return true
123
+ if (result.exitCode !== null && result.exitCode !== undefined) return false
124
+ if (typeof result.signal === 'string' && result.signal.length > 0) return false
125
+ return result.outcome === 'failed'
126
+ }
127
+
128
+ /** Build the structured error result the pipeline accepts for a failed call. */
129
+ export function createErrorResult(message, code = 'SSH_FAILED') {
130
+ const text = String(message ?? 'remote bash failed')
131
+ return {
132
+ isError: true,
133
+ content: [{ type: 'text', text: `Error: ${code}: ${text}` }],
134
+ error: { message: text, info: { name: 'SshOpsError', code } },
135
+ }
136
+ }
137
+
138
+ /**
139
+ * @param {object} deps
140
+ * @param {object} deps.runtime SSH runtime facade (`createSshRuntime`)
141
+ * @param {object} deps.targets per-session target store
142
+ * @param {object} deps.config resolved plugin config
143
+ * @param {() => object[]} deps.loadInventory
144
+ * @param {(entry: object) => void} [deps.recordActivity]
145
+ * @returns {(exec: object, next: () => Promise<object>) => Promise<object>}
146
+ */
147
+ export function createRemoteBashRouter({ runtime, targets, config, loadInventory, recordActivity } = {}) {
148
+ return async function remoteBashRouter(exec, next) {
149
+ if (!exec || exec.name !== BASH_TOOL_NAME) return next()
150
+ const session = exec.agent?.session
151
+ const key = session?.id ?? exec.agent?.id
152
+ const binding = typeof key === 'string' ? targets?.get(key) : undefined
153
+ if (!binding || !binding.active) {
154
+ // Several `@server` mentions clear the active target. Running bash locally
155
+ // in that state would execute commands meant for a server on the DSH host,
156
+ // so make the model choose a target first.
157
+ if (binding && Array.isArray(binding.candidates) && binding.candidates.length > 1) {
158
+ return createErrorResult(
159
+ `several servers were mentioned in this session (${binding.candidates.join(', ')}); ` +
160
+ 'call ssh_workbench_target({ server: "<name>" }) before running bash, or server: "local" to run locally',
161
+ 'TARGET_NOT_SELECTED',
162
+ )
163
+ }
164
+ return next()
165
+ }
166
+
167
+ const args = exec.arguments ?? {}
168
+ const command = typeof args.command === 'string' ? args.command : ''
169
+ if (!command.trim()) return next()
170
+
171
+ let servers
172
+ try {
173
+ servers = loadInventory()
174
+ } catch (error) {
175
+ return createErrorResult(`cannot read the managed server inventory: ${describeError(error)}`, 'INVENTORY_UNAVAILABLE')
176
+ }
177
+ const server = findTarget(servers, binding.active)
178
+ if (server === undefined) {
179
+ return createErrorResult(
180
+ `target "${binding.active}" is no longer in the managed inventory; call ${'ssh_workbench_target'} to pick another target or "local"`,
181
+ 'TARGET_NOT_FOUND',
182
+ )
183
+ }
184
+ if (server.enabled === false) {
185
+ return createErrorResult(
186
+ `target "${server.name}" is disabled; call ssh_workbench_target to pick another target or "local"`,
187
+ 'TARGET_DISABLED',
188
+ )
189
+ }
190
+ if (effectiveDialect(server) !== POSIX_PLATFORM) {
191
+ return createErrorResult(
192
+ `target "${server.name}" is a Windows server; the native bash tool only runs POSIX commands — use remote_pwsh for Windows targets`,
193
+ 'TARGET_PLATFORM_UNSUPPORTED',
194
+ )
195
+ }
196
+
197
+ const workdir = applyWorkdir(command, args.workdir)
198
+ if (workdir.error !== undefined) return createErrorResult(workdir.error, 'INVALID_WORKDIR')
199
+
200
+ // A detached command returns only remote facts that survive across
201
+ // transports: pid + log file. It is not registered in DSH's local jobs.
202
+ if (args.run_in_background === true) {
203
+ const startTimeout = Math.min(resolveTimeoutMs(args, config), 30000)
204
+ let start
205
+ try {
206
+ start = await runtime.exec(server, buildPosixBackgroundCommand(workdir.command), {
207
+ timeoutMs: startTimeout,
208
+ signal: exec.signal,
209
+ readOnly: false,
210
+ cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
211
+ stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
212
+ })
213
+ } catch (error) {
214
+ return createErrorResult(describeError(error), 'SSH_EXEC_FAILED')
215
+ }
216
+ try {
217
+ recordActivity?.({ kind: 'exec', server: server.name, summary: `[background] ${command.slice(0, 190)}`, outcome: describeResultOutcome(start) })
218
+ } catch {
219
+ // Activity recording is best effort.
220
+ }
221
+ if (isTransportFailure(start)) {
222
+ const detail = typeof start?.stderr === 'string' && start.stderr.trim() ? start.stderr.trim() : 'remote execution failed'
223
+ return createErrorResult(detail, start?.errorCode ?? 'SSH_EXEC_FAILED')
224
+ }
225
+ const started = parseBackgroundStart(start.stdout)
226
+ if (started.pid === undefined) {
227
+ const detail = String(start?.stderr ?? '').trim() || String(start?.stdout ?? '').trim()
228
+ return createErrorResult(`background start failed${detail ? `: ${detail}` : ''}`, 'REMOTE_BACKGROUND_FAILED')
229
+ }
230
+ const text = renderBackgroundStart({
231
+ pid: started.pid,
232
+ log: started.log,
233
+ errLog: started.errLog,
234
+ target: `${server.name} (${server.username}@${server.host}:${server.port})`,
235
+ command,
236
+ dialect: 'posix',
237
+ })
238
+ const synthetic = {
239
+ outcome: 'success',
240
+ exitCode: 0,
241
+ signal: null,
242
+ stdout: text,
243
+ stderr: '',
244
+ truncated: { stdout: false, stderr: false },
245
+ }
246
+ return { value: toBashValue(synthetic, { cwd: `${server.name}:${workdir.cwd}`, timeoutMs: startTimeout }) }
247
+ }
248
+
249
+ const timeoutMs = resolveTimeoutMs(args, config)
250
+
251
+ // Detect (and cache) whether the server can enforce a remote deadline, so
252
+ // a timed-out call also stops the remote work instead of leaving it behind.
253
+ let capability = { mode: 'none', shell: '/bin/sh' }
254
+ try {
255
+ if (typeof runtime.timeoutGuard === 'function') {
256
+ const probed = await runtime.timeoutGuard(server)
257
+ capability = typeof probed === 'string' ? { mode: probed } : (probed ?? capability)
258
+ }
259
+ } catch {
260
+ capability = { mode: 'none', shell: '/bin/sh' }
261
+ }
262
+ const remoteCommand = wrapRemoteTimeout(workdir.command, timeoutMs, capability, {
263
+ killAfterMs: config?.timeoutKillAfterMs,
264
+ })
265
+
266
+ let result
267
+ try {
268
+ result = await runtime.exec(server, remoteCommand, {
269
+ timeoutMs,
270
+ signal: exec.signal,
271
+ readOnly: false,
272
+ cap: Number.isInteger(config?.maxOutputBytes) ? config.maxOutputBytes : undefined,
273
+ stderrCap: Number.isInteger(config?.maxStderrBytes) ? config.maxStderrBytes : undefined,
274
+ })
275
+ } catch (error) {
276
+ return createErrorResult(describeError(error), 'SSH_EXEC_FAILED')
277
+ }
278
+
279
+ try {
280
+ recordActivity?.({
281
+ kind: 'exec',
282
+ server: server.name,
283
+ summary: command.slice(0, 200),
284
+ outcome: describeResultOutcome(result),
285
+ })
286
+ } catch {
287
+ // Activity recording is best effort.
288
+ }
289
+
290
+ if (isTransportFailure(result)) {
291
+ const code = typeof result?.errorCode === 'string' && result.errorCode ? result.errorCode : 'SSH_EXEC_FAILED'
292
+ const detail = typeof result?.stderr === 'string' && result.stderr.trim() ? result.stderr.trim() : 'remote execution failed'
293
+ const suggestion = typeof result?.suggestedAction === 'string' && result.suggestedAction ? `\n${result.suggestedAction}` : ''
294
+ return createErrorResult(`${detail}${suggestion}`, code)
295
+ }
296
+
297
+ // The cwd label is also what the native Bash card shows in its expanded
298
+ // prompt; prefixing the target makes a card self-describing in multi-target
299
+ // sessions without shadowing DSH's shipped Bash renderer.
300
+ let value = toBashValue(result, { cwd: `${server.name}:${workdir.cwd}`, timeoutMs })
301
+ const notices = []
302
+ // One line after each target change: a card-level "where did this run" hint.
303
+ if (binding.notifiedVersion !== binding.version && typeof targets?.markNotified === 'function') {
304
+ notices.push(`[remote] target: ${server.name} (${server.username}@${server.host}:${server.port})`)
305
+ targets.markNotified(key, binding.version)
306
+ }
307
+ if (result?.outcome === 'timeout') {
308
+ notices.push(
309
+ capability.mode === 'none'
310
+ ? '[remote] client-side timeout only: this server has no usable `timeout` utility, so the remote process may still be running.'
311
+ : `[remote] timeout: the remote command group was terminated (SIGTERM${capability.mode === 'kill' ? ', KILL after the remote deadline' : ''}).`,
312
+ )
313
+ }
314
+ if (result?.outcome === 'cancelled') {
315
+ notices.push(
316
+ capability.mode === 'none'
317
+ ? '[remote] cancelled: the SSH channel was closed, but this server has no usable `timeout` utility, so the remote process may still be running.'
318
+ : '[remote] cancelled: the SSH channel was closed; the remote wrapper terminates the command at its deadline if it is still running.',
319
+ )
320
+ }
321
+ if (args.sandbox_permissions !== undefined || args.justification !== undefined) {
322
+ notices.push('[remote] sandbox_permissions does not apply to managed servers; the command ran over SSH.')
323
+ }
324
+ if (notices.length > 0) {
325
+ const text = value.stderr.text
326
+ value = {
327
+ ...value,
328
+ stderr: { ...value.stderr, text: `${text}${text ? '\n' : ''}${notices.join('\n')}` },
329
+ }
330
+ }
331
+ return { value }
332
+ }
333
+ }
334
+
335
+ function describeError(error) {
336
+ return error instanceof Error ? error.message : String(error)
337
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Secret storage for SSH passwords/passphrases.
3
+ *
4
+ * Secrets live in a private JSON document, encrypted with AES-256-GCM using a
5
+ * per-installation random key (0600) generated on first use. The DSH
6
+ * credentials service is the long-term home for these values; until that
7
+ * contract is verified on the desktop runtime this self-contained store keeps
8
+ * plaintext off disk without blocking the plugin.
9
+ */
10
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
11
+ import fs from 'node:fs'
12
+ import { readTextFile, secretsKeyPath, secretsPath, writePrivateFile } from './paths.js'
13
+
14
+ const ALGO = 'aes-256-gcm'
15
+ const PREFIX = 'enc:v1:'
16
+
17
+ function loadKey(file = secretsKeyPath()) {
18
+ const text = readTextFile(file).trim()
19
+ if (text) {
20
+ const key = Buffer.from(text, 'base64')
21
+ if (key.length === 32) return key
22
+ }
23
+ const key = randomBytes(32)
24
+ writePrivateFile(file, `${key.toString('base64')}\n`)
25
+ return key
26
+ }
27
+
28
+ function readDoc(file) {
29
+ const text = readTextFile(file)
30
+ if (!text.trim()) return { version: 1, entries: {} }
31
+ try {
32
+ const parsed = JSON.parse(text)
33
+ if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') {
34
+ return parsed
35
+ }
36
+ } catch {
37
+ // corrupt document: fall through to an empty one rather than crashing boot
38
+ }
39
+ return { version: 1, entries: {} }
40
+ }
41
+
42
+ function encrypt(plain, key) {
43
+ const iv = randomBytes(12)
44
+ const cipher = createCipheriv(ALGO, key, iv)
45
+ const data = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()])
46
+ const tag = cipher.getAuthTag()
47
+ return PREFIX + Buffer.concat([iv, tag, data]).toString('base64')
48
+ }
49
+
50
+ function decrypt(token, key) {
51
+ if (typeof token !== 'string' || !token.startsWith(PREFIX)) return undefined
52
+ const payload = Buffer.from(token.slice(PREFIX.length), 'base64')
53
+ if (payload.length <= 28) return undefined
54
+ const iv = payload.subarray(0, 12)
55
+ const tag = payload.subarray(12, 28)
56
+ const data = payload.subarray(28)
57
+ const decipher = createDecipheriv(ALGO, key, iv)
58
+ decipher.setAuthTag(tag)
59
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
60
+ }
61
+
62
+ export function createSecretStore({ file = secretsPath(), keyFile = secretsKeyPath() } = {}) {
63
+ let key
64
+ const keyOf = () => {
65
+ if (!key) key = loadKey(keyFile)
66
+ return key
67
+ }
68
+ return {
69
+ get(recordKey) {
70
+ const doc = readDoc(file)
71
+ const token = doc.entries[recordKey]
72
+ if (token === undefined) return undefined
73
+ try {
74
+ return decrypt(token, keyOf())
75
+ } catch {
76
+ return undefined
77
+ }
78
+ },
79
+ has(recordKey) {
80
+ const doc = readDoc(file)
81
+ return doc.entries[recordKey] !== undefined
82
+ },
83
+ set(recordKey, value) {
84
+ const doc = readDoc(file)
85
+ doc.entries[recordKey] = encrypt(value, keyOf())
86
+ writePrivateFile(file, `${JSON.stringify(doc, null, 2)}\n`)
87
+ },
88
+ delete(recordKey) {
89
+ const doc = readDoc(file)
90
+ if (doc.entries[recordKey] === undefined) return false
91
+ delete doc.entries[recordKey]
92
+ writePrivateFile(file, `${JSON.stringify(doc, null, 2)}\n`)
93
+ return true
94
+ },
95
+ }
96
+ }