-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwindows.ts
More file actions
372 lines (348 loc) · 11.2 KB
/
windows.ts
File metadata and controls
372 lines (348 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
/**
* @file Windows backend via PowerShell CredentialManager module, with a
* DPAPI-encrypted file fallback. Two paths, tried in order:
*
* 1. CredentialManager PowerShell module (`New-StoredCredential` /
* `Get-StoredCredential` / `Remove-StoredCredential`). The cleanest path —
* stored credentials live in the Windows Credential Manager, the same
* place `cmdkey` writes to. Requires the module to be installed
* (`Install-Module CredentialManager -Scope CurrentUser`).
* 2. DPAPI-encrypted file under `%APPDATA%\<service>\<account>.enc`. Used when
* the CredentialManager module isn't available.
* `System.Security.Cryptography.ProtectedData` encrypts under the
* current-user machine key — readable only by this user on this machine,
* never plaintext. The target name composed for CredentialManager is
* `service:account` (matching `cmdkey /generic:<target>` convention).
*/
import {
spawn,
spawnSync,
} from '@socketsecurity/lib-stable/process/spawn/child'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import type fs from 'node:fs'
import { ErrorCtor } from '../primordials/error'
import { JSONStringify } from '../primordials/json'
import { PromiseCtor } from '../primordials/promise'
const POWERSHELL_BIN = 'powershell'
export function buildTarget(service: string, account: string): string {
return `${service}:${account}`
}
export async function deleteWindows(
service: string,
account: string,
): Promise<'removed' | 'absent'> {
const target = buildTarget(service, account)
let removedAny = false
// CredentialManager removal first.
const psScript = `
try { Remove-StoredCredential -Target ${quotePs(target)}; exit 0 }
catch { exit 1 }
`
const ps = await runPsAsync(psScript)
if (ps.status === 0) {
removedAny = true
}
// DPAPI file cleanup as well — both backends are independent.
const filePath = getDpapiFilePath(service, account)
if (existsSync(filePath)) {
try {
const { rmSync } = await import('node:fs')
rmSync(filePath, { force: true })
removedAny = true
} catch {
// best-effort
}
}
return removedAny ? 'removed' : 'absent'
}
export function deleteWindowsSync(
service: string,
account: string,
): 'removed' | 'absent' {
const target = buildTarget(service, account)
let removedAny = false
const psScript = `
try { Remove-StoredCredential -Target ${quotePs(target)}; exit 0 }
catch { exit 1 }
`
const ps = runPsSync(psScript)
if (ps.status === 0) {
removedAny = true
}
const filePath = getDpapiFilePath(service, account)
if (existsSync(filePath)) {
try {
const fsMod = require('node:fs') as typeof fs
fsMod.rmSync(filePath, { force: true })
removedAny = true
} catch {
// best-effort
}
}
return removedAny ? 'removed' : 'absent'
}
export function getDpapiFilePath(service: string, account: string): string {
validateKeychainComponent(service, 'service')
validateKeychainComponent(account, 'account')
const appData =
process.env['APPDATA'] ?? path.join(os.homedir(), 'AppData', 'Roaming')
return path.join(appData, service, `${account}.enc`)
}
export function isWindowsBackendAvailable(): boolean {
// PowerShell ships with Windows 10+; we treat the CredentialManager
// module + DPAPI fallback as a unified backend. If PowerShell is
// missing the host isn't a usable Windows for this library.
const r = spawnSync(POWERSHELL_BIN, ['-NoProfile', '-Command', 'exit 0'], {
stdio: 'ignore',
})
return r.status === 0
}
export function quotePs(value: string): string {
// PowerShell single-quoted strings: escape embedded ' by doubling.
return `'${value.replace(/'/g, "''")}'`
}
export async function readDpapi(filePath: string): Promise<string | undefined> {
if (!existsSync(filePath)) {
return undefined
}
const script = `
Add-Type -AssemblyName System.Security
$bytes = [Convert]::FromBase64String((Get-Content -Raw ${quotePs(filePath)}))
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, 'CurrentUser')
[System.Text.Encoding]::UTF8.GetString($plain)
`
const r = await runPsAsync(script)
if (r.status !== 0) {
return undefined
}
const out = r.stdout.trim()
return out || undefined
}
export function readDpapiSync(filePath: string): string | undefined {
if (!existsSync(filePath)) {
return undefined
}
const script = `
Add-Type -AssemblyName System.Security
$bytes = [Convert]::FromBase64String((Get-Content -Raw ${quotePs(filePath)}))
$plain = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, 'CurrentUser')
[System.Text.Encoding]::UTF8.GetString($plain)
`
const r = runPsSync(script)
if (r.status !== 0) {
return undefined
}
const out = r.stdout.trim()
return out || undefined
}
export async function readWindows(
service: string,
account: string,
): Promise<string | undefined> {
const target = buildTarget(service, account)
const script = `
try {
(Get-StoredCredential -Target ${quotePs(target)}).Password |
ConvertFrom-SecureString -AsPlainText
} catch { exit 1 }
`
const ps = await runPsAsync(script)
if (ps.status === 0) {
const out = ps.stdout.trim()
if (out) {
return out
}
}
return readDpapi(getDpapiFilePath(service, account))
}
export function readWindowsSync(
service: string,
account: string,
): string | undefined {
const target = buildTarget(service, account)
const script = `
try {
(Get-StoredCredential -Target ${quotePs(target)}).Password |
ConvertFrom-SecureString -AsPlainText
} catch { exit 1 }
`
const ps = runPsSync(script)
if (ps.status === 0) {
const out = ps.stdout.trim()
if (out) {
return out
}
}
return readDpapiSync(getDpapiFilePath(service, account))
}
export function runPsAsync(
script: string,
input?: string,
): Promise<{
status: number | null
stdout: string
stderr: string
}> {
return new PromiseCtor(resolve => {
const { process: cp } = spawn(
POWERSHELL_BIN,
['-NoProfile', '-Command', script],
{
stdio: ['pipe', 'pipe', 'pipe'],
},
)
let stdout = ''
let stderr = ''
cp.stdout!.setEncoding('utf8')
cp.stdout!.on('data', chunk => {
stdout += chunk
})
cp.stderr!.setEncoding('utf8')
cp.stderr!.on('data', chunk => {
stderr += chunk
})
cp.on('error', () => resolve({ status: -1, stdout, stderr }))
cp.on('close', status => resolve({ status, stdout, stderr }))
if (input !== undefined) {
cp.stdin!.end(input)
} else {
cp.stdin!.end()
}
})
}
export function runPsSync(
script: string,
input?: string,
): {
status: number | null
stdout: string
stderr: string
} {
const r = spawnSync(POWERSHELL_BIN, ['-NoProfile', '-Command', script], {
encoding: 'utf8',
input,
stdio: ['pipe', 'pipe', 'pipe'],
})
return { status: r.status, stdout: r.stdout, stderr: r.stderr }
}
/**
* Reject identifier components (service / account names) that contain path
* separators, `..` segments, or NUL bytes. These would escape the intended
* `%APPDATA%\<service>\<account>.enc` layout and let a caller read or overwrite
* arbitrary files under the user's profile.
*
* Throws on bad input rather than sanitizing — callers should pass logical
* identifiers (e.g. `socket-cli`, `SOCKET_API_KEY`), not paths.
*/
export function validateKeychainComponent(value: string, name: string): void {
if (
/[\\/]/.test(value) ||
value.includes('..') ||
value.includes('\0') ||
value === '' ||
value === '.'
) {
throw new ErrorCtor(
`secrets/windows: ${name} contains path-traversal characters: ${JSONStringify(value)}. Use a plain identifier (no \\\\, /, .., or NUL).`,
)
}
}
export async function writeDpapi(
filePath: string,
value: string,
): Promise<void> {
const dir = path.dirname(filePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const script = `
Add-Type -AssemblyName System.Security
$token = [Console]::In.ReadToEnd().Trim()
$bytes = [System.Text.Encoding]::UTF8.GetBytes($token)
$protected = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, 'CurrentUser')
[Convert]::ToBase64String($protected) | Set-Content -Path ${quotePs(filePath)} -NoNewline
`
const r = await runPsAsync(script, value)
if (r.status !== 0) {
throw new ErrorCtor(
`DPAPI file write failed: ${r.stderr.trim()}. ` +
'Install the CredentialManager PowerShell module (' +
'`Install-Module CredentialManager -Scope CurrentUser`) for a cleaner storage path.',
)
}
}
export function writeDpapiSync(filePath: string, value: string): void {
const dir = path.dirname(filePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const script = `
Add-Type -AssemblyName System.Security
$token = [Console]::In.ReadToEnd().Trim()
$bytes = [System.Text.Encoding]::UTF8.GetBytes($token)
$protected = [System.Security.Cryptography.ProtectedData]::Protect($bytes, $null, 'CurrentUser')
[Convert]::ToBase64String($protected) | Set-Content -Path ${quotePs(filePath)} -NoNewline
`
const r = runPsSync(script, value)
if (r.status !== 0) {
throw new ErrorCtor(
`DPAPI file write failed: ${r.stderr.trim()}. ` +
'Install the CredentialManager PowerShell module (' +
'`Install-Module CredentialManager -Scope CurrentUser`) for a cleaner storage path.',
)
}
}
export async function writeWindows(
service: string,
account: string,
value: string,
_label: string,
): Promise<void> {
const target = buildTarget(service, account)
// CredentialManager path first (cleanest). The token is piped on
// stdin so it doesn't appear in `Get-Process` argv.
const psScript = `
$token = $input | Out-String
$token = $token.Trim()
$secure = ConvertTo-SecureString $token -AsPlainText -Force
try {
New-StoredCredential -Target ${quotePs(target)} -UserName ${quotePs(account)} -SecurePassword $secure -Persist LocalMachine | Out-Null
exit 0
} catch { exit 1 }
`
const ps = await runPsAsync(psScript, value)
if (ps.status === 0) {
return
}
await writeDpapi(getDpapiFilePath(service, account), value)
}
export function writeWindowsSync(
service: string,
account: string,
value: string,
_label: string,
): void {
const target = buildTarget(service, account)
const psScript = `
$token = $input | Out-String
$token = $token.Trim()
$secure = ConvertTo-SecureString $token -AsPlainText -Force
try {
New-StoredCredential -Target ${quotePs(target)} -UserName ${quotePs(account)} -SecurePassword $secure -Persist LocalMachine | Out-Null
exit 0
} catch { exit 1 }
`
const ps = runPsSync(psScript, value)
if (ps.status === 0) {
return
}
writeDpapiSync(getDpapiFilePath(service, account), value)
}
// Silence unused-fs-import flags on macOS/Linux dev builds where this
// file's reading-side branches aren't exercised. The module is fine
// at runtime; the lint rule just doesn't know which platform we're on.
void readFileSync
void writeFileSync