dsh-ssh-vault 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +404 -0
- package/CHANGELOG.zh-CN.md +352 -0
- package/CONTRIBUTING.md +179 -0
- package/CONTRIBUTING.zh-CN.md +167 -0
- package/LICENSE +21 -0
- package/README.md +452 -2
- package/README.zh-CN.md +450 -0
- package/SECURITY.md +42 -0
- package/SECURITY.zh-CN.md +38 -0
- package/assets/banner.png +0 -0
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-1.png +0 -0
- package/client.js +1134 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +11 -0
- package/index.js +1180 -0
- package/lib/background.js +65 -0
- package/lib/i18n.js +589 -0
- package/lib/locales/ssh.js +130 -0
- package/lib/platform.js +68 -0
- package/lib/ssh.js +723 -0
- package/lib/vault-api.js +417 -0
- package/lib/vault.js +397 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +75 -4
- package/scripts/dhcp-reserve-kali.ps1 +81 -0
- package/scripts/make-market-icon.py +63 -0
- package/scripts/set-secret.mjs +127 -0
package/lib/vault.js
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 加密保险库(vault)—— 用 AES-256-GCM 保护 SSH 主机清单。
|
|
3
|
+
*
|
|
4
|
+
* 设计要点:
|
|
5
|
+
* - 主机地址、端口、账号、口令全部位于密文内部,磁盘上只留下 `vault.json` 的
|
|
6
|
+
* `iv` / `tag` / `data` 三段 base64,不出现任何明文。
|
|
7
|
+
* - 数据密钥(DEK)为 32 字节随机数,其保护方式由 `keyProtection` 决定:
|
|
8
|
+
* · `dpapi`(默认)—— 用 Windows DPAPI 以当前用户身份加密后写入 `key.json`,
|
|
9
|
+
* 文件被复制到其他机器或其他用户下都无法解开;
|
|
10
|
+
* · `file` —— 直接写入 `key.json`,安全边界等同于文件系统权限;
|
|
11
|
+
* · `passphrase` —— 由用户主口令经 scrypt 派生,磁盘上不保存密钥材料。
|
|
12
|
+
* - GCM 的附加认证数据(AAD)绑定格式版本,避免密文被降级重放。
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-ssh-vault/vault
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
|
|
18
|
+
import { spawn } from 'node:child_process'
|
|
19
|
+
import { existsSync } from 'node:fs'
|
|
20
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import path from 'node:path'
|
|
23
|
+
import { t } from './i18n.js'
|
|
24
|
+
import { assertSupportedPlatform } from './platform.js'
|
|
25
|
+
|
|
26
|
+
/** 保险库格式版本;同时参与 AAD 计算。 */
|
|
27
|
+
export const VAULT_VERSION = 1
|
|
28
|
+
/** AES-256 密钥长度(字节)。 */
|
|
29
|
+
const KEY_BYTES = 32
|
|
30
|
+
/** GCM 推荐 IV 长度(字节)。 */
|
|
31
|
+
const IV_BYTES = 12
|
|
32
|
+
/** scrypt 盐长度(字节)。 */
|
|
33
|
+
const SALT_BYTES = 16
|
|
34
|
+
/** GCM 认证标签长度(字节)。 */
|
|
35
|
+
const TAG_BYTES = 16
|
|
36
|
+
/** 绑定到密文的附加认证数据。 */
|
|
37
|
+
const AAD = Buffer.from('dsh-ssh-vault:v1', 'utf8')
|
|
38
|
+
/** scrypt 参数:约 32 MiB 内存开销,兼顾强度与启动延迟。 */
|
|
39
|
+
const SCRYPT = { N: 16384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }
|
|
40
|
+
|
|
41
|
+
/** 默认保险库目录:优先使用 DSH_HOME,保持与 Harness 其他私有数据同处一地。 */
|
|
42
|
+
export function defaultVaultDir() {
|
|
43
|
+
const home = process.env.DSH_HOME ?? path.join(homedir(), '.dsh')
|
|
44
|
+
return path.join(home, 'ssh-vault')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 用 Windows DPAPI 保护一段数据(仅当前用户可解密)。
|
|
49
|
+
* @param data - 待保护的原始字节。
|
|
50
|
+
* @returns base64 形式的受保护数据。
|
|
51
|
+
*/
|
|
52
|
+
async function dpapiProtect(data) {
|
|
53
|
+
return runPowerShell(
|
|
54
|
+
"Add-Type -AssemblyName System.Security;"
|
|
55
|
+
+ "$in=[Console]::In.ReadToEnd().Trim();"
|
|
56
|
+
+ "$b=[Convert]::FromBase64String($in);"
|
|
57
|
+
+ "$p=[System.Security.Cryptography.ProtectedData]::Protect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);"
|
|
58
|
+
+ "[Console]::Out.Write([Convert]::ToBase64String($p))",
|
|
59
|
+
data.toString('base64'),
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 解除 DPAPI 保护。
|
|
65
|
+
* @param payload - base64 形式的受保护数据。
|
|
66
|
+
* @returns 原始字节。
|
|
67
|
+
*/
|
|
68
|
+
async function dpapiUnprotect(payload) {
|
|
69
|
+
const out = await runPowerShell(
|
|
70
|
+
"Add-Type -AssemblyName System.Security;"
|
|
71
|
+
+ "$in=[Console]::In.ReadToEnd().Trim();"
|
|
72
|
+
+ "$b=[Convert]::FromBase64String($in);"
|
|
73
|
+
+ "$p=[System.Security.Cryptography.ProtectedData]::Unprotect($b,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser);"
|
|
74
|
+
+ "[Console]::Out.Write([Convert]::ToBase64String($p))",
|
|
75
|
+
payload,
|
|
76
|
+
)
|
|
77
|
+
return Buffer.from(out.trim(), 'base64')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 通过 stdin 传入数据并执行一段 Windows PowerShell,避免任何命令行转义问题。
|
|
82
|
+
* @param script - PowerShell 脚本,从 stdin 读取输入。
|
|
83
|
+
* @param input - 写入子进程 stdin 的文本。
|
|
84
|
+
* @returns 子进程的标准输出。
|
|
85
|
+
*/
|
|
86
|
+
function runPowerShell(script, input) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const systemRoot = process.env.SystemRoot ?? 'C:\\Windows'
|
|
89
|
+
const exe = path.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
90
|
+
const child = spawn(exe, ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
91
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
})
|
|
94
|
+
let stdout = ''
|
|
95
|
+
let stderr = ''
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
child.kill()
|
|
98
|
+
reject(new Error(t('vault.dpapiTimeout')))
|
|
99
|
+
}, 15000)
|
|
100
|
+
child.stdout.setEncoding('utf8')
|
|
101
|
+
child.stderr.setEncoding('utf8')
|
|
102
|
+
child.stdout.on('data', (chunk) => { stdout += chunk })
|
|
103
|
+
child.stderr.on('data', (chunk) => { stderr += chunk })
|
|
104
|
+
child.on('error', (error) => {
|
|
105
|
+
clearTimeout(timer)
|
|
106
|
+
reject(error)
|
|
107
|
+
})
|
|
108
|
+
child.on('close', (code) => {
|
|
109
|
+
clearTimeout(timer)
|
|
110
|
+
if (code === 0 && stdout.trim().length > 0) resolve(stdout)
|
|
111
|
+
else reject(new Error(t('vault.dpapiFailed', { code, output: stderr.trim() || t('vault.noOutput') })))
|
|
112
|
+
})
|
|
113
|
+
child.stdin.end(input)
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 对主机地址做脱敏,只保留网络前缀。
|
|
119
|
+
* @param host - 原始地址。
|
|
120
|
+
* @returns 形如 `192.168.*.*` 的掩码。
|
|
121
|
+
*/
|
|
122
|
+
export function maskHost(host) {
|
|
123
|
+
if (typeof host !== 'string' || host.length === 0) return '(unknown)'
|
|
124
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
|
125
|
+
const parts = host.split('.')
|
|
126
|
+
return `${parts[0]}.${parts[1]}.*.*`
|
|
127
|
+
}
|
|
128
|
+
const dot = host.indexOf('.')
|
|
129
|
+
if (dot > 0) return `${host.slice(0, dot)}.${'*'.repeat(Math.max(1, host.length - dot - 1))}`
|
|
130
|
+
return `${host.slice(0, 2)}${'*'.repeat(Math.max(1, host.length - 2))}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 对账号名做脱敏,保留首字符。
|
|
135
|
+
* @param user - 原始账号。
|
|
136
|
+
* @returns 掩码后的账号。
|
|
137
|
+
*/
|
|
138
|
+
export function maskUser(user) {
|
|
139
|
+
if (typeof user !== 'string' || user.length === 0) return '(unknown)'
|
|
140
|
+
if (user.length <= 2) return `${user[0]}*`
|
|
141
|
+
return `${user[0]}${'*'.repeat(Math.min(user.length - 1, 4))}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 加密保险库。所有主机记录都以密文形式保存在一个文件中,读取时才在内存解密。
|
|
146
|
+
*/
|
|
147
|
+
export class Vault {
|
|
148
|
+
#dir
|
|
149
|
+
#keyProtection
|
|
150
|
+
#masterPassword
|
|
151
|
+
#logger
|
|
152
|
+
#key = null
|
|
153
|
+
#mode = null
|
|
154
|
+
#hosts = null
|
|
155
|
+
#chain = Promise.resolve()
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param options - 保险库配置。
|
|
159
|
+
* @param options.dir - 保险库目录。
|
|
160
|
+
* @param options.keyProtection - 数据密钥的保护方式。
|
|
161
|
+
* @param options.masterPassword - `passphrase` 模式下的主口令。
|
|
162
|
+
* @param options.logger - 可选的日志接口。
|
|
163
|
+
*/
|
|
164
|
+
constructor({ dir, keyProtection = 'dpapi', masterPassword, logger } = {}) {
|
|
165
|
+
// 运行期兜底:非 Windows 上继续构造会在 #loadKey 里退化成明文 key.json。
|
|
166
|
+
// 直接拒绝,比「能跑但密钥明文落盘」诚实。
|
|
167
|
+
assertSupportedPlatform()
|
|
168
|
+
this.#dir = dir ?? defaultVaultDir()
|
|
169
|
+
this.#keyProtection = keyProtection
|
|
170
|
+
this.#masterPassword = masterPassword
|
|
171
|
+
this.#logger = logger
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 保险库目录(其中包含密文、密钥与 askpass 辅助脚本)。 */
|
|
175
|
+
get dir() {
|
|
176
|
+
return this.#dir
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 当前数据密钥的保护方式,初始化后可用。 */
|
|
180
|
+
get keyMode() {
|
|
181
|
+
return this.#mode
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
#file(name) {
|
|
185
|
+
return path.join(this.#dir, name)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async #ensureDir() {
|
|
189
|
+
await mkdir(this.#dir, { recursive: true })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 载入或首次创建数据密钥。 */
|
|
193
|
+
async #loadKey() {
|
|
194
|
+
if (this.#key !== null) return
|
|
195
|
+
await this.#ensureDir()
|
|
196
|
+
const keyPath = this.#file('key.json')
|
|
197
|
+
if (existsSync(keyPath)) {
|
|
198
|
+
const stored = JSON.parse(await readFile(keyPath, 'utf8'))
|
|
199
|
+
if (stored.mode === 'passphrase') {
|
|
200
|
+
if (typeof this.#masterPassword !== 'string' || this.#masterPassword.length === 0) {
|
|
201
|
+
throw new Error(t('vault.masterPasswordMissing'))
|
|
202
|
+
}
|
|
203
|
+
this.#key = scryptSync(this.#masterPassword, Buffer.from(stored.salt, 'base64'), KEY_BYTES, SCRYPT)
|
|
204
|
+
} else if (stored.mode === 'dpapi') {
|
|
205
|
+
try {
|
|
206
|
+
this.#key = await dpapiUnprotect(stored.payload)
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw new Error(t('vault.dpapiUnwrapFailed', {
|
|
209
|
+
message: error instanceof Error ? error.message : String(error),
|
|
210
|
+
}))
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
this.#key = Buffer.from(stored.payload, 'base64')
|
|
214
|
+
}
|
|
215
|
+
this.#mode = stored.mode
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
// 首次运行:生成数据密钥并按既定方式保护。
|
|
219
|
+
const key = randomBytes(KEY_BYTES)
|
|
220
|
+
let record
|
|
221
|
+
if (this.#keyProtection === 'passphrase') {
|
|
222
|
+
if (typeof this.#masterPassword !== 'string' || this.#masterPassword.length === 0) {
|
|
223
|
+
throw new Error(t('vault.masterPasswordRequired'))
|
|
224
|
+
}
|
|
225
|
+
const salt = randomBytes(SALT_BYTES)
|
|
226
|
+
record = { version: VAULT_VERSION, mode: 'passphrase', salt: salt.toString('base64') }
|
|
227
|
+
await writeFile(keyPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
|
|
228
|
+
this.#key = scryptSync(this.#masterPassword, salt, KEY_BYTES, SCRYPT)
|
|
229
|
+
this.#mode = 'passphrase'
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
if (this.#keyProtection === 'dpapi') {
|
|
233
|
+
try {
|
|
234
|
+
const payload = await dpapiProtect(key)
|
|
235
|
+
record = { version: VAULT_VERSION, mode: 'dpapi', payload }
|
|
236
|
+
await writeFile(keyPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
|
|
237
|
+
this.#key = key
|
|
238
|
+
this.#mode = 'dpapi'
|
|
239
|
+
return
|
|
240
|
+
} catch (error) {
|
|
241
|
+
// DPAPI 不可用时降级为明文密钥文件,并明确告知使用者当前安全边界。
|
|
242
|
+
this.#logger?.warn?.(t('vault.dpapiUnavailable', {
|
|
243
|
+
message: error instanceof Error ? error.message : String(error),
|
|
244
|
+
}))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
record = { version: VAULT_VERSION, mode: 'file', payload: key.toString('base64') }
|
|
248
|
+
await writeFile(keyPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8')
|
|
249
|
+
this.#key = key
|
|
250
|
+
this.#mode = 'file'
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 读取并解密主机清单。 */
|
|
254
|
+
async #loadHosts() {
|
|
255
|
+
if (this.#hosts !== null) return this.#hosts
|
|
256
|
+
await this.#loadKey()
|
|
257
|
+
const vaultPath = this.#file('vault.json')
|
|
258
|
+
if (!existsSync(vaultPath)) {
|
|
259
|
+
this.#hosts = { version: VAULT_VERSION, hosts: {} }
|
|
260
|
+
return this.#hosts
|
|
261
|
+
}
|
|
262
|
+
const envelope = JSON.parse(await readFile(vaultPath, 'utf8'))
|
|
263
|
+
const decipher = createDecipheriv('aes-256-gcm', this.#key, Buffer.from(envelope.iv, 'base64'))
|
|
264
|
+
decipher.setAAD(AAD)
|
|
265
|
+
decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'))
|
|
266
|
+
let plain
|
|
267
|
+
try {
|
|
268
|
+
plain = Buffer.concat([decipher.update(Buffer.from(envelope.data, 'base64')), decipher.final()])
|
|
269
|
+
} catch {
|
|
270
|
+
throw new Error(t('vault.decryptFailed'))
|
|
271
|
+
}
|
|
272
|
+
const parsed = JSON.parse(plain.toString('utf8'))
|
|
273
|
+
this.#hosts = { version: VAULT_VERSION, hosts: parsed.hosts ?? {} }
|
|
274
|
+
return this.#hosts
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 原子地把主机清单加密落盘。 */
|
|
278
|
+
async #storeHosts() {
|
|
279
|
+
await this.#loadKey()
|
|
280
|
+
const iv = randomBytes(IV_BYTES)
|
|
281
|
+
const cipher = createCipheriv('aes-256-gcm', this.#key, iv)
|
|
282
|
+
cipher.setAAD(AAD)
|
|
283
|
+
const plain = Buffer.from(JSON.stringify(this.#hosts), 'utf8')
|
|
284
|
+
const data = Buffer.concat([cipher.update(plain), cipher.final()])
|
|
285
|
+
const tag = cipher.getAuthTag()
|
|
286
|
+
const envelope = {
|
|
287
|
+
version: VAULT_VERSION,
|
|
288
|
+
alg: 'aes-256-gcm',
|
|
289
|
+
iv: iv.toString('base64'),
|
|
290
|
+
tag: tag.toString('base64'),
|
|
291
|
+
data: data.toString('base64'),
|
|
292
|
+
}
|
|
293
|
+
const target = this.#file('vault.json')
|
|
294
|
+
const temp = this.#file(`vault.json.${process.pid}.tmp`)
|
|
295
|
+
await writeFile(temp, `${JSON.stringify(envelope)}\n`, 'utf8')
|
|
296
|
+
await rename(temp, target)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 在保险库锁内执行一次读改写,避免并发工具调用互相覆盖。
|
|
301
|
+
* @param fn - 收到当前主机表并返回结果的操作。
|
|
302
|
+
* @returns 操作结果。
|
|
303
|
+
*/
|
|
304
|
+
#locked(fn) {
|
|
305
|
+
const run = this.#chain.then(fn, fn)
|
|
306
|
+
this.#chain = run.then(() => undefined, () => undefined)
|
|
307
|
+
return run
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 列出全部主机记录(含明文,仅供插件内部使用,调用方负责脱敏)。
|
|
312
|
+
* @returns 主机记录数组。
|
|
313
|
+
*/
|
|
314
|
+
async list() {
|
|
315
|
+
return this.#locked(async () => {
|
|
316
|
+
const data = await this.#loadHosts()
|
|
317
|
+
return Object.values(data.hosts).sort((a, b) => a.alias.localeCompare(b.alias))
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 读取一台主机。
|
|
323
|
+
* @param alias - 主机别名。
|
|
324
|
+
* @returns 主机记录,不存在时为 `undefined`。
|
|
325
|
+
*/
|
|
326
|
+
async get(alias) {
|
|
327
|
+
return this.#locked(async () => {
|
|
328
|
+
const data = await this.#loadHosts()
|
|
329
|
+
return data.hosts[alias]
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* 新增或整体替换一台主机。
|
|
335
|
+
* @param record - 完整的主机记录,至少包含 `alias`。
|
|
336
|
+
* @returns 写入后的记录。
|
|
337
|
+
*/
|
|
338
|
+
async put(record) {
|
|
339
|
+
return this.#locked(async () => {
|
|
340
|
+
const data = await this.#loadHosts()
|
|
341
|
+
const now = new Date().toISOString()
|
|
342
|
+
const previous = data.hosts[record.alias]
|
|
343
|
+
const next = {
|
|
344
|
+
...record,
|
|
345
|
+
createdAt: previous?.createdAt ?? now,
|
|
346
|
+
updatedAt: now,
|
|
347
|
+
}
|
|
348
|
+
data.hosts[record.alias] = next
|
|
349
|
+
await this.#storeHosts()
|
|
350
|
+
return next
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 删除一台主机。
|
|
356
|
+
* @param alias - 主机别名。
|
|
357
|
+
* @returns 是否确实删除了记录。
|
|
358
|
+
*/
|
|
359
|
+
async remove(alias) {
|
|
360
|
+
return this.#locked(async () => {
|
|
361
|
+
const data = await this.#loadHosts()
|
|
362
|
+
if (data.hosts[alias] === undefined) return false
|
|
363
|
+
delete data.hosts[alias]
|
|
364
|
+
await this.#storeHosts()
|
|
365
|
+
return true
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* 就地更新一台主机的部分字段(口令等敏感字段始终整体替换)。
|
|
371
|
+
* @param alias - 主机别名。
|
|
372
|
+
* @param patch - 需要覆盖的字段。
|
|
373
|
+
* @returns 更新后的记录,主机不存在时为 `undefined`。
|
|
374
|
+
*/
|
|
375
|
+
async patch(alias, patch) {
|
|
376
|
+
return this.#locked(async () => {
|
|
377
|
+
const data = await this.#loadHosts()
|
|
378
|
+
const previous = data.hosts[alias]
|
|
379
|
+
if (previous === undefined) return undefined
|
|
380
|
+
const next = { ...previous, ...patch, alias, updatedAt: new Date().toISOString() }
|
|
381
|
+
data.hosts[alias] = next
|
|
382
|
+
await this.#storeHosts()
|
|
383
|
+
return next
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** 保险库中 `known_hosts` 文件的路径(OpenSSH 主机密钥记录)。 */
|
|
388
|
+
get knownHostsPath() {
|
|
389
|
+
return this.#file('known_hosts')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 等待目录就绪,供需要写入辅助脚本的调用方使用。 */
|
|
393
|
+
async ensureDir() {
|
|
394
|
+
await this.#ensureDir()
|
|
395
|
+
return this.#dir
|
|
396
|
+
}
|
|
397
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-ssh-vault",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Encrypted SSH host vault and model-facing SSH tools for the DeepSeek Harness — Windows only",
|
|
6
|
+
"os": [
|
|
7
|
+
"win32"
|
|
8
|
+
],
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "scientificmaniac",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/scientificmaniac/dsh-ssh-vault.git"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/scientificmaniac/dsh-ssh-vault/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/scientificmaniac/dsh-ssh-vault#readme",
|
|
19
|
+
"keywords": [
|
|
20
|
+
"dsh",
|
|
21
|
+
"deepseek-harness",
|
|
22
|
+
"cordis-plugin",
|
|
23
|
+
"ssh",
|
|
24
|
+
"vault",
|
|
25
|
+
"encryption"
|
|
26
|
+
],
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node test/platform.mjs && node test/diagnose.mjs && node test/unit.mjs && node test/background.mjs && node test/vault-api.mjs && node test/i18n.mjs && node test/host-apply.mjs && node test/client-smoke.mjs",
|
|
32
|
+
"test:live": "node test/live.mjs",
|
|
33
|
+
"test:self": "node test/selftest.mjs"
|
|
34
|
+
},
|
|
35
|
+
"exports": {
|
|
36
|
+
".": "./index.js",
|
|
37
|
+
"./client": "./client.js",
|
|
38
|
+
"./package.json": "./package.json",
|
|
39
|
+
"./locale/*.json": "./locale/*.json"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"index.js",
|
|
43
|
+
"client.js",
|
|
44
|
+
"lib",
|
|
45
|
+
"scripts",
|
|
46
|
+
"locale",
|
|
47
|
+
"cordis.patch.yml",
|
|
48
|
+
"icon.svg",
|
|
49
|
+
"assets",
|
|
50
|
+
"README.md",
|
|
51
|
+
"README.zh-CN.md",
|
|
52
|
+
"CHANGELOG.md",
|
|
53
|
+
"CHANGELOG.zh-CN.md",
|
|
54
|
+
"CONTRIBUTING.md",
|
|
55
|
+
"CONTRIBUTING.zh-CN.md",
|
|
56
|
+
"SECURITY.md",
|
|
57
|
+
"SECURITY.zh-CN.md",
|
|
58
|
+
"LICENSE"
|
|
59
|
+
],
|
|
60
|
+
"icon": "./icon.svg",
|
|
61
|
+
"meta": {
|
|
62
|
+
"title": "SSH Vault (Windows)",
|
|
63
|
+
"description": "Connect to remote hosts over SSH from Windows with the host address, port, account, and password kept in an encrypted vault."
|
|
64
|
+
},
|
|
65
|
+
"dsh": {
|
|
66
|
+
"bundle": {
|
|
67
|
+
"patch": "./cordis.patch.yml"
|
|
68
|
+
},
|
|
69
|
+
"client": {
|
|
70
|
+
"platform": "web",
|
|
71
|
+
"immediately": true,
|
|
72
|
+
"inject": [
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#Requires -RunAsAdministrator
|
|
2
|
+
<#
|
|
3
|
+
.SYNOPSIS
|
|
4
|
+
给 Kali 虚拟机做 VMware DHCP 地址保留(固定租约)。
|
|
5
|
+
|
|
6
|
+
.DESCRIPTION
|
|
7
|
+
把 kali 的 MAC 地址钉死到 192.0.2.150,这样 VMware 的 DHCP 每次都会把同一个地址
|
|
8
|
+
发给它,不会因为 DHCP 池重新分配而漂移。
|
|
9
|
+
|
|
10
|
+
脚本可重复运行:已经存在同名 host 块时会更新而不是重复追加。
|
|
11
|
+
|
|
12
|
+
.PARAMETER Mac
|
|
13
|
+
Kali eth0 的 MAC 地址。默认 00:0c:29:00:00:01(示例值,请按实际填写)。
|
|
14
|
+
|
|
15
|
+
.PARAMETER Address
|
|
16
|
+
要保留的地址。默认 192.0.2.150。
|
|
17
|
+
|
|
18
|
+
.PARAMETER HostName
|
|
19
|
+
配置文件里的条目名,仅作标识,不影响行为。
|
|
20
|
+
|
|
21
|
+
.NOTES
|
|
22
|
+
VMware 在增删虚拟网卡时会重写 vmnetdhcp.conf,那时需要重新运行本脚本。
|
|
23
|
+
#>
|
|
24
|
+
[CmdletBinding()]
|
|
25
|
+
param(
|
|
26
|
+
[string]$Mac = '00:0c:29:00:00:01',
|
|
27
|
+
[string]$Address = '192.0.2.150',
|
|
28
|
+
[string]$HostName = 'kali'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
$ErrorActionPreference = 'Stop'
|
|
32
|
+
|
|
33
|
+
$conf = Join-Path $env:ProgramData 'VMware\vmnetdhcp.conf'
|
|
34
|
+
if (-not (Test-Path $conf)) {
|
|
35
|
+
throw "找不到 VMware DHCP 配置:$conf"
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# 1. 先备份。保留最近几份即可,避免堆满目录。
|
|
39
|
+
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
40
|
+
$backup = "$conf.bak-$stamp"
|
|
41
|
+
Copy-Item -LiteralPath $conf -Destination $backup -Force
|
|
42
|
+
Write-Host "已备份配置到 $backup" -ForegroundColor Green
|
|
43
|
+
|
|
44
|
+
$text = Get-Content -LiteralPath $conf -Raw
|
|
45
|
+
$blockPattern = "(?ms)^[ \t]*host[ \t]+$([regex]::Escape($HostName))[ \t]*\{.*?^[ \t]*\}[ \t]*\r?\n?"
|
|
46
|
+
|
|
47
|
+
# 2. 已经有了就替换,保证幂等。
|
|
48
|
+
$newBlock = @"
|
|
49
|
+
host $HostName {
|
|
50
|
+
hardware ethernet $Mac;
|
|
51
|
+
fixed-address $Address;
|
|
52
|
+
}
|
|
53
|
+
"@
|
|
54
|
+
|
|
55
|
+
if ($text -match $blockPattern) {
|
|
56
|
+
$text = [regex]::Replace($text, $blockPattern, ($newBlock + "`r`n"), 1)
|
|
57
|
+
Write-Host "已更新既有的 host $HostName 条目" -ForegroundColor Yellow
|
|
58
|
+
} else {
|
|
59
|
+
if (-not $text.EndsWith("`n")) { $text += "`r`n" }
|
|
60
|
+
$text += "`r`n# DHCP 保留:$HostName($Mac -> $Address),由 dhcp-reserve-kali.ps1 添加`r`n"
|
|
61
|
+
$text += $newBlock
|
|
62
|
+
Write-Host "已追加 host $HostName 条目" -ForegroundColor Yellow
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
Set-Content -LiteralPath $conf -Value $text -NoNewline -Encoding utf8
|
|
66
|
+
|
|
67
|
+
# 3. 重启 DHCP 服务让新配置生效。
|
|
68
|
+
$service = Get-Service -Name 'VMnetDHCP' -ErrorAction SilentlyContinue
|
|
69
|
+
if ($null -eq $service) {
|
|
70
|
+
Write-Warning '没找到 VMnetDHCP 服务,配置已写入但需要手动重启 VMware DHCP。'
|
|
71
|
+
} else {
|
|
72
|
+
Restart-Service -Name 'VMnetDHCP' -Force
|
|
73
|
+
Write-Host '已重启 VMnetDHCP 服务' -ForegroundColor Green
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
Write-Host ''
|
|
77
|
+
Write-Host '完成。接下来在 Kali 里释放并重新获取租约:' -ForegroundColor Cyan
|
|
78
|
+
Write-Host ' sudo dhclient -r eth0 && sudo dhclient eth0'
|
|
79
|
+
Write-Host ' ip -4 addr show eth0'
|
|
80
|
+
Write-Host ''
|
|
81
|
+
Write-Host "预期拿到 $Address。" -ForegroundColor Cyan
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""生成 dsh-market 用的位图图标(180x180 PNG)。
|
|
2
|
+
|
|
3
|
+
市场卡片约 96px 显示,所以图形必须是大色块、少细节。主题是「加密的 SSH 通道」:
|
|
4
|
+
深色圆角底 + 终端提示符 + 一把锁,用几何图形画,不依赖任何字体文件。
|
|
5
|
+
"""
|
|
6
|
+
from PIL import Image, ImageDraw
|
|
7
|
+
|
|
8
|
+
SIZE = 180
|
|
9
|
+
SCALE = 2 # 先画 2 倍再缩小,边缘更干净
|
|
10
|
+
S = SIZE * SCALE
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def rounded_rect(draw, box, radius, fill):
|
|
14
|
+
draw.rounded_rectangle(box, radius=radius, fill=fill)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
img = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
|
18
|
+
d = ImageDraw.Draw(img)
|
|
19
|
+
|
|
20
|
+
# —— 底板:深色圆角方块 ——
|
|
21
|
+
rounded_rect(d, [0, 0, S - 1, S - 1], radius=40 * SCALE, fill=(22, 24, 30, 255))
|
|
22
|
+
|
|
23
|
+
# —— 终端窗口:比底板略小的圆角矩形,稍亮一档 ——
|
|
24
|
+
pad = 26 * SCALE
|
|
25
|
+
win = [pad, pad + 6 * SCALE, S - pad, S - pad]
|
|
26
|
+
rounded_rect(d, win, radius=14 * SCALE, fill=(32, 36, 46, 255))
|
|
27
|
+
|
|
28
|
+
# 窗口标题栏三个点
|
|
29
|
+
dot_r = 4 * SCALE
|
|
30
|
+
dot_y = win[1] + 12 * SCALE
|
|
31
|
+
for i, color in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]):
|
|
32
|
+
cx = win[0] + (14 + i * 14) * SCALE
|
|
33
|
+
d.ellipse([cx - dot_r, dot_y - dot_r, cx + dot_r, dot_y + dot_r], fill=color)
|
|
34
|
+
|
|
35
|
+
# —— 提示符 ">" ——
|
|
36
|
+
accent = (86, 182, 255)
|
|
37
|
+
cx, cy = win[0] + 26 * SCALE, win[1] + 44 * SCALE
|
|
38
|
+
d.line([cx, cy, cx + 13 * SCALE, cy + 13 * SCALE], fill=accent, width=6 * SCALE)
|
|
39
|
+
d.line([cx + 13 * SCALE, cy + 13 * SCALE, cx, cy + 26 * SCALE], fill=accent, width=6 * SCALE)
|
|
40
|
+
# 光标下划线
|
|
41
|
+
d.line([cx + 19 * SCALE, cy + 26 * SCALE, cx + 38 * SCALE, cy + 26 * SCALE],
|
|
42
|
+
fill=accent, width=6 * SCALE)
|
|
43
|
+
|
|
44
|
+
# —— 锁:压在终端右下角,表示"加密" ——
|
|
45
|
+
lw, lh = 40 * SCALE, 34 * SCALE
|
|
46
|
+
lx, ly = S - pad - lw + 14 * SCALE, win[3] - lh + 10 * SCALE
|
|
47
|
+
shackle_w = 22 * SCALE
|
|
48
|
+
shackle_top = ly - 16 * SCALE
|
|
49
|
+
shackle_x = lx + (lw - shackle_w) // 2
|
|
50
|
+
d.arc([shackle_x, shackle_top, shackle_x + shackle_w, ly + 8 * SCALE],
|
|
51
|
+
start=180, end=360, fill=(255, 199, 89), width=6 * SCALE)
|
|
52
|
+
|
|
53
|
+
lock_fill = (255, 199, 89)
|
|
54
|
+
rounded_rect(d, [lx, ly, lx + lw, ly + lh], radius=6 * SCALE, fill=lock_fill)
|
|
55
|
+
# 锁孔:圆 + 竖槽
|
|
56
|
+
k_cx, k_cy = lx + lw // 2, ly + lh // 2 - 2 * SCALE
|
|
57
|
+
d.ellipse([k_cx - 4 * SCALE, k_cy - 4 * SCALE, k_cx + 4 * SCALE, k_cy + 4 * SCALE],
|
|
58
|
+
fill=(32, 36, 46, 255))
|
|
59
|
+
d.line([k_cx, k_cy, k_cx, k_cy + 9 * SCALE], fill=(32, 36, 46, 255), width=4 * SCALE)
|
|
60
|
+
|
|
61
|
+
img = img.resize((SIZE, SIZE), Image.LANCZOS)
|
|
62
|
+
img.save("assets/icon-180.png", "PNG")
|
|
63
|
+
print("wrote assets/icon-180.png", img.size)
|