dsh-remote 0.8.71 → 0.8.73
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/LICENSE +21 -21
- package/README.en.md +389 -389
- package/README.md +462 -462
- package/cordis.patch.yml +9 -9
- package/lib/audit.js +45 -0
- package/lib/binding.js +172 -172
- package/lib/client.js +4022 -3990
- package/lib/credential.js +263 -263
- package/lib/errors.js +141 -118
- package/lib/exec-job.js +235 -235
- package/lib/file-reference.js +457 -457
- package/lib/forwards.js +258 -258
- package/lib/hostkey.js +108 -108
- package/lib/http-transport.js +196 -196
- package/lib/ignore.js +202 -202
- package/lib/index.js +4550 -4548
- package/lib/instructions-dynamic.js +415 -415
- package/lib/openssh.js +879 -879
- package/lib/paths.js +252 -252
- package/lib/pool.js +963 -886
- package/lib/registry.js +296 -293
- package/lib/remote-fs.js +173 -173
- package/lib/remote-sandbox.js +183 -183
- package/lib/remote-shell-child.cjs +344 -305
- package/lib/remote-shell.js +542 -518
- package/lib/routes-fs.js +245 -245
- package/lib/routes-term.js +169 -169
- package/lib/search.js +235 -235
- package/lib/sftp-surface.js +119 -119
- package/lib/sshconfig.js +779 -705
- package/lib/sync.js +275 -275
- package/lib/tasks.js +95 -95
- package/lib/telemetry.js +213 -213
- package/lib/terminal.js +205 -205
- package/lib/update.js +472 -472
- package/lib/web-attach.js +850 -850
- package/package.json +100 -99
package/lib/credential.js
CHANGED
|
@@ -1,263 +1,263 @@
|
|
|
1
|
-
// dsh-remote — optional OS-keychain password storage.
|
|
2
|
-
// Machines keep their password in the OS credential store instead of the
|
|
3
|
-
// plaintext machines.json when the user enables "加密保存密码" per machine.
|
|
4
|
-
// Every backend is best-effort: any failure resolves to {ok:false} and the
|
|
5
|
-
// caller (persistPassword) falls back to plaintext + a user-visible warning
|
|
6
|
-
// (the feature must never block connecting, and must never silently drop the
|
|
7
|
-
// credential — issue #30).
|
|
8
|
-
//
|
|
9
|
-
// Backends:
|
|
10
|
-
// darwin → `security` (login keychain, generic password)
|
|
11
|
-
// win32 → DPAPI via PowerShell (CurrentUser scope), files under
|
|
12
|
-
// $DSH_HOME/remote-workspaces/.secrets/
|
|
13
|
-
// linux → `secret-tool` (libsecret / gnome-keyring), optional
|
|
14
|
-
// else → unsupported (plain)
|
|
15
|
-
import { spawn } from 'node:child_process'
|
|
16
|
-
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
|
|
17
|
-
import { join as joinPath } from 'node:path'
|
|
18
|
-
|
|
19
|
-
const SERVICE = 'dsh-remote'
|
|
20
|
-
const TIMEOUT = 8000
|
|
21
|
-
|
|
22
|
-
// Windows PowerShell 5.1 does not preload System.Security, so
|
|
23
|
-
// [System.Security.Cryptography.ProtectedData] is "not found" until the assembly
|
|
24
|
-
// is added (issue #30: without this the whole DPAPI call failed and the caller
|
|
25
|
-
// silently lost the password). `-ErrorAction SilentlyContinue` keeps the line
|
|
26
|
-
// harmless on PowerShell 7, where the type is already available.
|
|
27
|
-
const PS_SECURITY_ASSEMBLY = 'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;'
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Run a helper and resolve with its trimmed stdout.
|
|
31
|
-
*
|
|
32
|
-
* WHY `spawn` and not `execFile(..., { input })`: measured on a real Ubuntu box
|
|
33
|
-
* with a real gnome-keyring, `secret-tool store` given its password through
|
|
34
|
-
* `execFile`'s `input` option **never saw EOF**, blocked on the read, and was
|
|
35
|
-
* killed by this timeout — so on Linux "encrypt the password" always failed after
|
|
36
|
-
* 8 seconds with the useless message "Command failed: secret-tool store …" (empty
|
|
37
|
-
* stderr) and the credential was dropped. The same command with an explicitly
|
|
38
|
-
* ended stdin (`spawn` + `stdin.end()`, or `sh -c '… < file'`) finishes in ~20ms.
|
|
39
|
-
* Nothing in this module needs the extra `execFile` machinery, so stdin is now
|
|
40
|
-
* always closed explicitly and the child's stderr is carried on the error (the old
|
|
41
|
-
* path dropped it, which is why the failure had no reason attached).
|
|
42
|
-
*/
|
|
43
|
-
function run(bin, args, input) {
|
|
44
|
-
return new Promise((resolve, reject) => {
|
|
45
|
-
let child
|
|
46
|
-
try {
|
|
47
|
-
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
48
|
-
} catch (err) {
|
|
49
|
-
reject(err)
|
|
50
|
-
return
|
|
51
|
-
}
|
|
52
|
-
let out = ''
|
|
53
|
-
let errOut = ''
|
|
54
|
-
let settled = false
|
|
55
|
-
const timer = setTimeout(() => {
|
|
56
|
-
if (settled) return
|
|
57
|
-
settled = true
|
|
58
|
-
try { child.kill('SIGTERM') } catch { /* already gone */ }
|
|
59
|
-
const err = new Error(`${bin} timed out after ${TIMEOUT}ms`)
|
|
60
|
-
err.stderr = errOut
|
|
61
|
-
err.code = 'ETIMEDOUT'
|
|
62
|
-
reject(err)
|
|
63
|
-
}, TIMEOUT)
|
|
64
|
-
const done = (err, value) => {
|
|
65
|
-
if (settled) return
|
|
66
|
-
settled = true
|
|
67
|
-
clearTimeout(timer)
|
|
68
|
-
if (err) reject(err); else resolve(value)
|
|
69
|
-
}
|
|
70
|
-
child.on('error', (err) => done(err))
|
|
71
|
-
child.on('close', (code) => {
|
|
72
|
-
if (code === 0) return done(null, out.trim())
|
|
73
|
-
const err = new Error(`${bin} exited with ${code}${errOut.trim() ? ': ' + errOut.trim().slice(0, 300) : ''}`)
|
|
74
|
-
err.code = code
|
|
75
|
-
err.stderr = errOut
|
|
76
|
-
done(err)
|
|
77
|
-
})
|
|
78
|
-
child.stdout.on('data', (d) => { out += d })
|
|
79
|
-
child.stderr.on('data', (d) => { errOut += d })
|
|
80
|
-
// Always end stdin: a helper that reads a secret from stdin needs EOF, and one
|
|
81
|
-
// that does not must not inherit an open pipe.
|
|
82
|
-
try { child.stdin.end(input == null ? '' : String(input)) } catch { /* closed early */ }
|
|
83
|
-
})
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function platformBackend() {
|
|
87
|
-
if (process.platform === 'darwin') return 'keychain'
|
|
88
|
-
if (process.platform === 'win32') return 'windows'
|
|
89
|
-
return 'secret' // linux (secret-tool may be absent → falls back)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function account(machineId) {
|
|
93
|
-
// Only machine ids are stored; sanitize just in case.
|
|
94
|
-
return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Redact a failed command's diagnostics: execFile puts the FULL command line
|
|
98
|
-
* (which carries the password as an argv) into `err.message`, and this string is
|
|
99
|
-
* shown in the settings UI — so the secret must never survive it. */
|
|
100
|
-
function safeReason(err, password) {
|
|
101
|
-
const raw = [err && err.stderr, err && err.message, err && err.code]
|
|
102
|
-
.filter(Boolean).map(String).join(' ')
|
|
103
|
-
const secret = String(password == null ? '' : password)
|
|
104
|
-
const scrubbed = secret ? raw.split(secret).join('***') : raw
|
|
105
|
-
return scrubbed.replace(/\s+/g, ' ').trim().slice(0, 300)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** PowerShell one-liner that DPAPI-protects a password (CurrentUser scope).
|
|
109
|
-
* Exported because the issue #30 fix (the mandatory Add-Type preamble) is only
|
|
110
|
-
* observable here — a Windows host cannot be exercised from the test suite. */
|
|
111
|
-
export function dpapiProtectScript(password) {
|
|
112
|
-
return PS_SECURITY_ASSEMBLY +
|
|
113
|
-
`$p='${String(password).replace(/'/g, "''")}';` +
|
|
114
|
-
`$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
|
|
115
|
-
`[Convert]::ToBase64String($s)`
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** PowerShell one-liner that reverses dpapiProtectScript(). */
|
|
119
|
-
export function dpapiUnprotectScript(b64) {
|
|
120
|
-
return PS_SECURITY_ASSEMBLY +
|
|
121
|
-
`$s=[Convert]::FromBase64String('${String(b64).replace(/'/g, "''")}');` +
|
|
122
|
-
`[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Save a password to the OS store. Resolves {ok, backend, error} — ok:false on
|
|
126
|
-
* any failure (caller falls back to plaintext). `error` is a password-free
|
|
127
|
-
* diagnostic for the UI. */
|
|
128
|
-
export async function saveSecret(machineId, password, secretsDir) {
|
|
129
|
-
const acc = account(machineId)
|
|
130
|
-
try {
|
|
131
|
-
if (process.platform === 'darwin') {
|
|
132
|
-
await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
|
|
133
|
-
return { ok: true, backend: 'keychain', error: '' }
|
|
134
|
-
}
|
|
135
|
-
if (process.platform === 'win32') {
|
|
136
|
-
// DPAPI-encrypt and store under the harness home.
|
|
137
|
-
const script = dpapiProtectScript(password)
|
|
138
|
-
const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
|
|
139
|
-
if (!b64) return { ok: false, backend: 'windows', error: 'DPAPI returned no ciphertext' }
|
|
140
|
-
mkdirSync(secretsDir || '.', { recursive: true })
|
|
141
|
-
writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
|
|
142
|
-
return { ok: true, backend: 'windows', error: '' }
|
|
143
|
-
}
|
|
144
|
-
if (process.platform === 'linux') {
|
|
145
|
-
await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
|
|
146
|
-
return { ok: true, backend: 'secret', error: '' }
|
|
147
|
-
}
|
|
148
|
-
} catch (err) {
|
|
149
|
-
return { ok: false, backend: platformBackend(), error: safeReason(err, password) || 'secret store write failed' }
|
|
150
|
-
}
|
|
151
|
-
return { ok: false, backend: platformBackend(), error: `unsupported platform: ${process.platform}` }
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Decide where a machine password is persisted (issue #30).
|
|
155
|
-
*
|
|
156
|
-
* The contract the settings UI relies on: a password is NEVER silently dropped.
|
|
157
|
-
* Historically that guarantee was upheld by falling back to PLAINTEXT in
|
|
158
|
-
* machines.json, which is a bad trade in one specific way: the user asked for
|
|
159
|
-
* encryption, the store failed, and the secret ended up less protected than if
|
|
160
|
-
* they had never asked. Worse, a locked keychain / missing secret-tool is
|
|
161
|
-
* exactly the moment when quietly writing a password to a world-readable file
|
|
162
|
-
* on disk is most likely to go unnoticed.
|
|
163
|
-
*
|
|
164
|
-
* So the fallback is a POLICY, not a fixed behaviour (`onStoreFailure`):
|
|
165
|
-
*
|
|
166
|
-
* `refuse` (default) — do not store the password at all. The machine is
|
|
167
|
-
* saved WITHOUT a credential and the caller reports how to fix the
|
|
168
|
-
* store; the user retypes (or connects) and nothing lands in
|
|
169
|
-
* plaintext by accident. `stored: 'none'` makes that explicit.
|
|
170
|
-
* `plain` — the legacy behaviour: write it to machines.json and raise
|
|
171
|
-
* `warning: 'secret-store-failed'`. Opt-in only, for users who
|
|
172
|
-
* genuinely prefer a working machine over the storage guarantee.
|
|
173
|
-
*
|
|
174
|
-
* An explicit `backend: 'plain'` still means plain by REQUEST (the user turned
|
|
175
|
-
* encryption off), and is unaffected by this policy.
|
|
176
|
-
*
|
|
177
|
-
* `save` is injectable so both paths are unit-testable on any platform.
|
|
178
|
-
*
|
|
179
|
-
* @param {object} arg
|
|
180
|
-
* @param {string} arg.backend - the platform backend name ('keychain'|'windows'|'secret'|'plain').
|
|
181
|
-
* @param {string} arg.machineId
|
|
182
|
-
* @param {string} arg.password
|
|
183
|
-
* @param {string} [arg.secretsDir]
|
|
184
|
-
* @param {'refuse'|'plain'} [arg.onStoreFailure] - see above.
|
|
185
|
-
* @param {Function} [arg.save] - injectable secure-store writer.
|
|
186
|
-
* @returns {Promise<{stored:'secret'|'plain'|'none', credentialBackend:string, password:string, warning:string, error:string}>}
|
|
187
|
-
*/
|
|
188
|
-
export async function persistPassword({
|
|
189
|
-
backend, machineId, password, secretsDir,
|
|
190
|
-
onStoreFailure = 'refuse',
|
|
191
|
-
save = saveSecret,
|
|
192
|
-
}) {
|
|
193
|
-
const pw = password == null ? '' : String(password)
|
|
194
|
-
const want = backend && backend !== 'plain' ? backend : 'plain'
|
|
195
|
-
if (!pw) return { stored: 'none', credentialBackend: want, password: '', warning: '', error: '' }
|
|
196
|
-
if (want === 'plain') return { stored: 'plain', credentialBackend: 'plain', password: pw, warning: '', error: '' }
|
|
197
|
-
let res
|
|
198
|
-
try {
|
|
199
|
-
res = await save(machineId, pw, secretsDir)
|
|
200
|
-
} catch (err) {
|
|
201
|
-
res = { ok: false, error: safeReason(err, pw) }
|
|
202
|
-
}
|
|
203
|
-
if (res && res.ok) {
|
|
204
|
-
return { stored: 'secret', credentialBackend: res.backend || want, password: '', warning: '', error: '' }
|
|
205
|
-
}
|
|
206
|
-
const error = String((res && res.error) || '') || 'secret store write failed'
|
|
207
|
-
if (onStoreFailure === 'plain') {
|
|
208
|
-
return {
|
|
209
|
-
stored: 'plain',
|
|
210
|
-
credentialBackend: 'plain',
|
|
211
|
-
password: pw,
|
|
212
|
-
warning: 'secret-store-failed',
|
|
213
|
-
error,
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
// Default: refuse. Nothing is written, and the caller surfaces the store
|
|
217
|
-
// failure plus the platform-specific repair hint instead of a silent downgrade.
|
|
218
|
-
return {
|
|
219
|
-
stored: 'none',
|
|
220
|
-
credentialBackend: want,
|
|
221
|
-
password: '',
|
|
222
|
-
warning: 'secret-store-refused',
|
|
223
|
-
error,
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/** Fetch a stored password. Resolves string|null. */
|
|
228
|
-
export async function getSecret(machineId, secretsDir) {
|
|
229
|
-
const acc = account(machineId)
|
|
230
|
-
try {
|
|
231
|
-
if (process.platform === 'darwin') {
|
|
232
|
-
return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
|
|
233
|
-
}
|
|
234
|
-
if (process.platform === 'win32') {
|
|
235
|
-
const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
|
|
236
|
-
if (!b64) return null
|
|
237
|
-
const script = dpapiUnprotectScript(b64)
|
|
238
|
-
return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
|
|
239
|
-
}
|
|
240
|
-
if (process.platform === 'linux') {
|
|
241
|
-
return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
|
|
242
|
-
}
|
|
243
|
-
} catch { /* fall through */ }
|
|
244
|
-
return null
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** Delete a stored password (idempotent). */
|
|
248
|
-
export async function deleteSecret(machineId, secretsDir) {
|
|
249
|
-
const acc = account(machineId)
|
|
250
|
-
try {
|
|
251
|
-
if (process.platform === 'darwin') {
|
|
252
|
-
await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
|
|
253
|
-
return
|
|
254
|
-
}
|
|
255
|
-
if (process.platform === 'win32') {
|
|
256
|
-
try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
|
|
257
|
-
return
|
|
258
|
-
}
|
|
259
|
-
if (process.platform === 'linux') {
|
|
260
|
-
await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
|
|
261
|
-
}
|
|
262
|
-
} catch { /* best effort */ }
|
|
263
|
-
}
|
|
1
|
+
// dsh-remote — optional OS-keychain password storage.
|
|
2
|
+
// Machines keep their password in the OS credential store instead of the
|
|
3
|
+
// plaintext machines.json when the user enables "加密保存密码" per machine.
|
|
4
|
+
// Every backend is best-effort: any failure resolves to {ok:false} and the
|
|
5
|
+
// caller (persistPassword) falls back to plaintext + a user-visible warning
|
|
6
|
+
// (the feature must never block connecting, and must never silently drop the
|
|
7
|
+
// credential — issue #30).
|
|
8
|
+
//
|
|
9
|
+
// Backends:
|
|
10
|
+
// darwin → `security` (login keychain, generic password)
|
|
11
|
+
// win32 → DPAPI via PowerShell (CurrentUser scope), files under
|
|
12
|
+
// $DSH_HOME/remote-workspaces/.secrets/
|
|
13
|
+
// linux → `secret-tool` (libsecret / gnome-keyring), optional
|
|
14
|
+
// else → unsupported (plain)
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'node:fs'
|
|
17
|
+
import { join as joinPath } from 'node:path'
|
|
18
|
+
|
|
19
|
+
const SERVICE = 'dsh-remote'
|
|
20
|
+
const TIMEOUT = 8000
|
|
21
|
+
|
|
22
|
+
// Windows PowerShell 5.1 does not preload System.Security, so
|
|
23
|
+
// [System.Security.Cryptography.ProtectedData] is "not found" until the assembly
|
|
24
|
+
// is added (issue #30: without this the whole DPAPI call failed and the caller
|
|
25
|
+
// silently lost the password). `-ErrorAction SilentlyContinue` keeps the line
|
|
26
|
+
// harmless on PowerShell 7, where the type is already available.
|
|
27
|
+
const PS_SECURITY_ASSEMBLY = 'Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue;'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run a helper and resolve with its trimmed stdout.
|
|
31
|
+
*
|
|
32
|
+
* WHY `spawn` and not `execFile(..., { input })`: measured on a real Ubuntu box
|
|
33
|
+
* with a real gnome-keyring, `secret-tool store` given its password through
|
|
34
|
+
* `execFile`'s `input` option **never saw EOF**, blocked on the read, and was
|
|
35
|
+
* killed by this timeout — so on Linux "encrypt the password" always failed after
|
|
36
|
+
* 8 seconds with the useless message "Command failed: secret-tool store …" (empty
|
|
37
|
+
* stderr) and the credential was dropped. The same command with an explicitly
|
|
38
|
+
* ended stdin (`spawn` + `stdin.end()`, or `sh -c '… < file'`) finishes in ~20ms.
|
|
39
|
+
* Nothing in this module needs the extra `execFile` machinery, so stdin is now
|
|
40
|
+
* always closed explicitly and the child's stderr is carried on the error (the old
|
|
41
|
+
* path dropped it, which is why the failure had no reason attached).
|
|
42
|
+
*/
|
|
43
|
+
function run(bin, args, input) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
let child
|
|
46
|
+
try {
|
|
47
|
+
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
48
|
+
} catch (err) {
|
|
49
|
+
reject(err)
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
let out = ''
|
|
53
|
+
let errOut = ''
|
|
54
|
+
let settled = false
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
if (settled) return
|
|
57
|
+
settled = true
|
|
58
|
+
try { child.kill('SIGTERM') } catch { /* already gone */ }
|
|
59
|
+
const err = new Error(`${bin} timed out after ${TIMEOUT}ms`)
|
|
60
|
+
err.stderr = errOut
|
|
61
|
+
err.code = 'ETIMEDOUT'
|
|
62
|
+
reject(err)
|
|
63
|
+
}, TIMEOUT)
|
|
64
|
+
const done = (err, value) => {
|
|
65
|
+
if (settled) return
|
|
66
|
+
settled = true
|
|
67
|
+
clearTimeout(timer)
|
|
68
|
+
if (err) reject(err); else resolve(value)
|
|
69
|
+
}
|
|
70
|
+
child.on('error', (err) => done(err))
|
|
71
|
+
child.on('close', (code) => {
|
|
72
|
+
if (code === 0) return done(null, out.trim())
|
|
73
|
+
const err = new Error(`${bin} exited with ${code}${errOut.trim() ? ': ' + errOut.trim().slice(0, 300) : ''}`)
|
|
74
|
+
err.code = code
|
|
75
|
+
err.stderr = errOut
|
|
76
|
+
done(err)
|
|
77
|
+
})
|
|
78
|
+
child.stdout.on('data', (d) => { out += d })
|
|
79
|
+
child.stderr.on('data', (d) => { errOut += d })
|
|
80
|
+
// Always end stdin: a helper that reads a secret from stdin needs EOF, and one
|
|
81
|
+
// that does not must not inherit an open pipe.
|
|
82
|
+
try { child.stdin.end(input == null ? '' : String(input)) } catch { /* closed early */ }
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function platformBackend() {
|
|
87
|
+
if (process.platform === 'darwin') return 'keychain'
|
|
88
|
+
if (process.platform === 'win32') return 'windows'
|
|
89
|
+
return 'secret' // linux (secret-tool may be absent → falls back)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function account(machineId) {
|
|
93
|
+
// Only machine ids are stored; sanitize just in case.
|
|
94
|
+
return String(machineId || '').replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Redact a failed command's diagnostics: execFile puts the FULL command line
|
|
98
|
+
* (which carries the password as an argv) into `err.message`, and this string is
|
|
99
|
+
* shown in the settings UI — so the secret must never survive it. */
|
|
100
|
+
function safeReason(err, password) {
|
|
101
|
+
const raw = [err && err.stderr, err && err.message, err && err.code]
|
|
102
|
+
.filter(Boolean).map(String).join(' ')
|
|
103
|
+
const secret = String(password == null ? '' : password)
|
|
104
|
+
const scrubbed = secret ? raw.split(secret).join('***') : raw
|
|
105
|
+
return scrubbed.replace(/\s+/g, ' ').trim().slice(0, 300)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** PowerShell one-liner that DPAPI-protects a password (CurrentUser scope).
|
|
109
|
+
* Exported because the issue #30 fix (the mandatory Add-Type preamble) is only
|
|
110
|
+
* observable here — a Windows host cannot be exercised from the test suite. */
|
|
111
|
+
export function dpapiProtectScript(password) {
|
|
112
|
+
return PS_SECURITY_ASSEMBLY +
|
|
113
|
+
`$p='${String(password).replace(/'/g, "''")}';` +
|
|
114
|
+
`$s=[System.Security.Cryptography.ProtectedData]::Protect([Text.Encoding]::UTF8.GetBytes($p),$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);` +
|
|
115
|
+
`[Convert]::ToBase64String($s)`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** PowerShell one-liner that reverses dpapiProtectScript(). */
|
|
119
|
+
export function dpapiUnprotectScript(b64) {
|
|
120
|
+
return PS_SECURITY_ASSEMBLY +
|
|
121
|
+
`$s=[Convert]::FromBase64String('${String(b64).replace(/'/g, "''")}');` +
|
|
122
|
+
`[Text.Encoding]::UTF8.GetString([System.Security.Cryptography.ProtectedData]::Unprotect($s,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser))`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Save a password to the OS store. Resolves {ok, backend, error} — ok:false on
|
|
126
|
+
* any failure (caller falls back to plaintext). `error` is a password-free
|
|
127
|
+
* diagnostic for the UI. */
|
|
128
|
+
export async function saveSecret(machineId, password, secretsDir) {
|
|
129
|
+
const acc = account(machineId)
|
|
130
|
+
try {
|
|
131
|
+
if (process.platform === 'darwin') {
|
|
132
|
+
await run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', acc, '-w', String(password)])
|
|
133
|
+
return { ok: true, backend: 'keychain', error: '' }
|
|
134
|
+
}
|
|
135
|
+
if (process.platform === 'win32') {
|
|
136
|
+
// DPAPI-encrypt and store under the harness home.
|
|
137
|
+
const script = dpapiProtectScript(password)
|
|
138
|
+
const b64 = await run('powershell', ['-NoProfile', '-STA', '-Command', script])
|
|
139
|
+
if (!b64) return { ok: false, backend: 'windows', error: 'DPAPI returned no ciphertext' }
|
|
140
|
+
mkdirSync(secretsDir || '.', { recursive: true })
|
|
141
|
+
writeFileSync(joinPath(secretsDir || '.', acc + '.bin'), b64, 'utf8')
|
|
142
|
+
return { ok: true, backend: 'windows', error: '' }
|
|
143
|
+
}
|
|
144
|
+
if (process.platform === 'linux') {
|
|
145
|
+
await run('secret-tool', ['store', '--label=' + SERVICE, 'service', SERVICE, 'account', acc], String(password) + '\n')
|
|
146
|
+
return { ok: true, backend: 'secret', error: '' }
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
return { ok: false, backend: platformBackend(), error: safeReason(err, password) || 'secret store write failed' }
|
|
150
|
+
}
|
|
151
|
+
return { ok: false, backend: platformBackend(), error: `unsupported platform: ${process.platform}` }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Decide where a machine password is persisted (issue #30).
|
|
155
|
+
*
|
|
156
|
+
* The contract the settings UI relies on: a password is NEVER silently dropped.
|
|
157
|
+
* Historically that guarantee was upheld by falling back to PLAINTEXT in
|
|
158
|
+
* machines.json, which is a bad trade in one specific way: the user asked for
|
|
159
|
+
* encryption, the store failed, and the secret ended up less protected than if
|
|
160
|
+
* they had never asked. Worse, a locked keychain / missing secret-tool is
|
|
161
|
+
* exactly the moment when quietly writing a password to a world-readable file
|
|
162
|
+
* on disk is most likely to go unnoticed.
|
|
163
|
+
*
|
|
164
|
+
* So the fallback is a POLICY, not a fixed behaviour (`onStoreFailure`):
|
|
165
|
+
*
|
|
166
|
+
* `refuse` (default) — do not store the password at all. The machine is
|
|
167
|
+
* saved WITHOUT a credential and the caller reports how to fix the
|
|
168
|
+
* store; the user retypes (or connects) and nothing lands in
|
|
169
|
+
* plaintext by accident. `stored: 'none'` makes that explicit.
|
|
170
|
+
* `plain` — the legacy behaviour: write it to machines.json and raise
|
|
171
|
+
* `warning: 'secret-store-failed'`. Opt-in only, for users who
|
|
172
|
+
* genuinely prefer a working machine over the storage guarantee.
|
|
173
|
+
*
|
|
174
|
+
* An explicit `backend: 'plain'` still means plain by REQUEST (the user turned
|
|
175
|
+
* encryption off), and is unaffected by this policy.
|
|
176
|
+
*
|
|
177
|
+
* `save` is injectable so both paths are unit-testable on any platform.
|
|
178
|
+
*
|
|
179
|
+
* @param {object} arg
|
|
180
|
+
* @param {string} arg.backend - the platform backend name ('keychain'|'windows'|'secret'|'plain').
|
|
181
|
+
* @param {string} arg.machineId
|
|
182
|
+
* @param {string} arg.password
|
|
183
|
+
* @param {string} [arg.secretsDir]
|
|
184
|
+
* @param {'refuse'|'plain'} [arg.onStoreFailure] - see above.
|
|
185
|
+
* @param {Function} [arg.save] - injectable secure-store writer.
|
|
186
|
+
* @returns {Promise<{stored:'secret'|'plain'|'none', credentialBackend:string, password:string, warning:string, error:string}>}
|
|
187
|
+
*/
|
|
188
|
+
export async function persistPassword({
|
|
189
|
+
backend, machineId, password, secretsDir,
|
|
190
|
+
onStoreFailure = 'refuse',
|
|
191
|
+
save = saveSecret,
|
|
192
|
+
}) {
|
|
193
|
+
const pw = password == null ? '' : String(password)
|
|
194
|
+
const want = backend && backend !== 'plain' ? backend : 'plain'
|
|
195
|
+
if (!pw) return { stored: 'none', credentialBackend: want, password: '', warning: '', error: '' }
|
|
196
|
+
if (want === 'plain') return { stored: 'plain', credentialBackend: 'plain', password: pw, warning: '', error: '' }
|
|
197
|
+
let res
|
|
198
|
+
try {
|
|
199
|
+
res = await save(machineId, pw, secretsDir)
|
|
200
|
+
} catch (err) {
|
|
201
|
+
res = { ok: false, error: safeReason(err, pw) }
|
|
202
|
+
}
|
|
203
|
+
if (res && res.ok) {
|
|
204
|
+
return { stored: 'secret', credentialBackend: res.backend || want, password: '', warning: '', error: '' }
|
|
205
|
+
}
|
|
206
|
+
const error = String((res && res.error) || '') || 'secret store write failed'
|
|
207
|
+
if (onStoreFailure === 'plain') {
|
|
208
|
+
return {
|
|
209
|
+
stored: 'plain',
|
|
210
|
+
credentialBackend: 'plain',
|
|
211
|
+
password: pw,
|
|
212
|
+
warning: 'secret-store-failed',
|
|
213
|
+
error,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
// Default: refuse. Nothing is written, and the caller surfaces the store
|
|
217
|
+
// failure plus the platform-specific repair hint instead of a silent downgrade.
|
|
218
|
+
return {
|
|
219
|
+
stored: 'none',
|
|
220
|
+
credentialBackend: want,
|
|
221
|
+
password: '',
|
|
222
|
+
warning: 'secret-store-refused',
|
|
223
|
+
error,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Fetch a stored password. Resolves string|null. */
|
|
228
|
+
export async function getSecret(machineId, secretsDir) {
|
|
229
|
+
const acc = account(machineId)
|
|
230
|
+
try {
|
|
231
|
+
if (process.platform === 'darwin') {
|
|
232
|
+
return await run('security', ['find-generic-password', '-s', SERVICE, '-a', acc, '-w'])
|
|
233
|
+
}
|
|
234
|
+
if (process.platform === 'win32') {
|
|
235
|
+
const b64 = readFileSync(joinPath(secretsDir || '.', acc + '.bin'), 'utf8').trim()
|
|
236
|
+
if (!b64) return null
|
|
237
|
+
const script = dpapiUnprotectScript(b64)
|
|
238
|
+
return await run('powershell', ['-NoProfile', '-STA', '-Command', script])
|
|
239
|
+
}
|
|
240
|
+
if (process.platform === 'linux') {
|
|
241
|
+
return await run('secret-tool', ['lookup', 'service', SERVICE, 'account', acc])
|
|
242
|
+
}
|
|
243
|
+
} catch { /* fall through */ }
|
|
244
|
+
return null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Delete a stored password (idempotent). */
|
|
248
|
+
export async function deleteSecret(machineId, secretsDir) {
|
|
249
|
+
const acc = account(machineId)
|
|
250
|
+
try {
|
|
251
|
+
if (process.platform === 'darwin') {
|
|
252
|
+
await run('security', ['delete-generic-password', '-s', SERVICE, '-a', acc]).catch(() => {})
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
if (process.platform === 'win32') {
|
|
256
|
+
try { unlinkSync(joinPath(secretsDir || '.', acc + '.bin')) } catch { /* absent */ }
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
if (process.platform === 'linux') {
|
|
260
|
+
await run('secret-tool', ['clear', 'service', SERVICE, 'account', acc]).catch(() => {})
|
|
261
|
+
}
|
|
262
|
+
} catch { /* best effort */ }
|
|
263
|
+
}
|