@tiphareth/dsh-hardssh 0.1.2 → 0.2.3
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 +30 -0
- package/README.md +77 -50
- package/SKILLS.md +140 -0
- package/lib/base/index.js +95 -0
- package/lib/client.js +1171 -1321
- package/lib/fs.js +333 -416
- package/lib/index.js +5428 -2991
- package/lib/ledger-B-LXlftp.js +43 -0
- package/lib/ledger-D2ezq1iW.js +376 -0
- package/lib/model-Cp7f70Mb.js +5 -0
- package/lib/registry-CViuzKYI.js +421 -0
- package/lib/subprocess.js +92 -549
- package/lib/types/backend.d.ts +73 -78
- package/lib/types/base/capability.d.ts +4 -0
- package/lib/types/base/ledger-router.d.ts +42 -26
- package/lib/types/base/ledger.d.ts +30 -19
- package/lib/types/base/model.d.ts +17 -8
- package/lib/types/base/plugin.d.ts +11 -0
- package/lib/types/base/registry.d.ts +28 -31
- package/lib/types/base/router.d.ts +0 -7
- package/lib/types/client/api.d.ts +8 -8
- package/lib/types/client/connect-host.d.ts +13 -0
- package/lib/types/client/index.d.ts +6 -4
- package/lib/types/client/locales.d.ts +15 -1
- package/lib/types/client/session-connect-gate.d.ts +71 -0
- package/lib/types/client/ssh/api.d.ts +11 -9
- package/lib/types/client/ssh/apply.d.ts +14 -8
- package/lib/types/client/ssh/locales.d.ts +8 -1
- package/lib/types/client/ssh/ops-tab.d.ts +34 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +3 -1
- package/lib/types/client/ssh/panel/ConnectionErrorDialog.d.ts +8 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +4 -1
- package/lib/types/client/ssh/panel/SshPanel.d.ts +8 -8
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +3 -5
- package/lib/types/client/ssh/panel/TransferTab.d.ts +5 -1
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +3 -1
- package/lib/types/client/ssh/session-target.d.ts +17 -0
- package/lib/types/client/state.d.ts +4 -1
- package/lib/types/client/workspace-panel-entry.d.ts +30 -0
- package/lib/types/client/workspace-panel.d.ts +24 -0
- package/lib/types/client-http.d.ts +77 -6
- package/lib/types/core.d.ts +5 -16
- package/lib/types/fs.d.ts +42 -25
- package/lib/types/index.d.ts +138 -9
- package/lib/types/ledger.d.ts +16 -111
- package/lib/types/protocol.d.ts +0 -5
- package/lib/types/providers/index.d.ts +6 -3
- package/lib/types/providers/local/provider.d.ts +83 -23
- package/lib/types/providers/ssh/provider.d.ts +24 -57
- package/lib/types/remote/environment.d.ts +2 -0
- package/lib/types/remote/remote-fs.d.ts +12 -1
- package/lib/types/remote/remote-process.d.ts +22 -1
- package/lib/types/remote/remote-subprocess.d.ts +13 -1
- package/lib/types/remote/remote-terminal.d.ts +4 -0
- package/lib/types/remote-search.d.ts +3 -3
- package/lib/types/routes.d.ts +36 -8
- package/lib/types/runtime/dsh-capabilities.d.ts +18 -0
- package/lib/types/runtime/workspace-core.d.ts +73 -38
- package/lib/types/runtime/workspace-migration.d.ts +113 -0
- package/lib/types/ssh/connection/lease.d.ts +23 -0
- package/lib/types/ssh/connection/manager.d.ts +359 -0
- package/lib/types/ssh/connection/pool.d.ts +8 -1
- package/lib/types/ssh/engine.d.ts +97 -267
- package/lib/types/ssh/known-hosts.d.ts +22 -5
- package/lib/types/ssh/local-transfer-policy.d.ts +20 -0
- package/lib/types/ssh/plugin.d.ts +10 -15
- package/lib/types/ssh/protocol.d.ts +18 -0
- package/lib/types/ssh/routes.d.ts +15 -7
- package/lib/types/ssh/sftp/service.d.ts +216 -0
- package/lib/types/ssh/store.d.ts +20 -8
- package/lib/types/ssh/terminal/service.d.ts +49 -0
- package/lib/types/ssh/tunnel/service.d.ts +26 -0
- package/lib/types/ssh/vault.d.ts +51 -4
- package/lib/types/subprocess.d.ts +13 -21
- package/lib/types/switch/switch-fs.d.ts +52 -2
- package/lib/types/switch/switch-subprocess.d.ts +32 -3
- package/lib/types/tools.d.ts +7 -6
- package/lib/types/workspace-tool-ops.d.ts +33 -0
- package/lib/types/workspace.d.ts +18 -0
- package/lib/vault-3gpWct2Q.js +559 -0
- package/lib/workspace.js +2 -0
- package/package.json +59 -26
- package/scripts/export-legacy-workspaces.mjs +136 -0
- package/src/backend.ts +155 -483
- package/src/base/capability.ts +4 -0
- package/src/base/ledger-router.ts +214 -65
- package/src/base/ledger.ts +175 -48
- package/src/base/model.ts +18 -8
- package/src/base/plugin.ts +15 -4
- package/src/base/registry.ts +49 -41
- package/src/base/router.ts +0 -8
- package/src/client/api.ts +8 -36
- package/src/client/connect-host.ts +199 -0
- package/src/client/directory-flow.tsx +91 -36
- package/src/client/index.ts +56 -53
- package/src/client/locales.ts +14 -0
- package/src/client/session-connect-gate.ts +163 -0
- package/src/client/ssh/api.ts +245 -108
- package/src/client/ssh/apply.ts +23 -24
- package/src/client/ssh/locales.ts +16 -2
- package/src/client/ssh/ops-tab.tsx +79 -0
- package/src/client/ssh/panel/ClusterTab.tsx +9 -20
- package/src/client/ssh/panel/ConnectionErrorDialog.tsx +33 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +5 -1
- package/src/client/ssh/panel/SshPanel.tsx +81 -77
- package/src/client/ssh/panel/TerminalTab.tsx +18 -37
- package/src/client/ssh/panel/TransferTab.tsx +43 -33
- package/src/client/ssh/panel/TunnelsTab.tsx +9 -26
- package/src/client/ssh/panel/panel.module.css +90 -127
- package/src/client/ssh/session-target.ts +69 -0
- package/src/client/state.ts +9 -1
- package/src/client/workspace-badges.ts +99 -96
- package/src/client/workspace-panel-entry.tsx +91 -0
- package/src/client/workspace-panel.tsx +227 -0
- package/src/client/workspace.module.css +76 -2
- package/src/client-http.ts +118 -18
- package/src/core.ts +37 -47
- package/src/fs.ts +171 -85
- package/src/index.ts +535 -244
- package/src/ledger.ts +62 -416
- package/src/protocol.ts +0 -6
- package/src/providers/index.ts +8 -4
- package/src/providers/local/provider.ts +259 -87
- package/src/providers/ssh/provider.ts +158 -155
- package/src/remote/environment.ts +22 -1
- package/src/remote/remote-fs.ts +123 -34
- package/src/remote/remote-process.ts +265 -203
- package/src/remote/remote-subprocess.ts +61 -18
- package/src/remote/remote-terminal.ts +106 -21
- package/src/remote-search.ts +122 -26
- package/src/routes.ts +416 -395
- package/src/runtime/dsh-capabilities.ts +19 -0
- package/src/runtime/workspace-core.ts +171 -88
- package/src/runtime/workspace-migration.ts +472 -0
- package/src/ssh/connection/lease.ts +35 -0
- package/src/ssh/connection/manager.ts +1083 -0
- package/src/ssh/connection/pool.ts +341 -275
- package/src/ssh/engine.ts +269 -1477
- package/src/ssh/known-hosts.ts +42 -18
- package/src/ssh/local-transfer-policy.ts +83 -0
- package/src/ssh/plugin.ts +20 -19
- package/src/ssh/protocol.ts +22 -1
- package/src/ssh/routes.ts +419 -187
- package/src/ssh/sftp/service.ts +967 -0
- package/src/ssh/store.ts +169 -72
- package/src/ssh/terminal/service.ts +177 -0
- package/src/ssh/tools.ts +42 -20
- package/src/ssh/tunnel/service.ts +217 -0
- package/src/ssh/vault.ts +245 -91
- package/src/subprocess.ts +88 -71
- package/src/switch/switch-fs.ts +141 -10
- package/src/switch/switch-subprocess.ts +80 -6
- package/src/tools.ts +217 -221
- package/src/workspace-tool-ops.ts +101 -0
- package/src/workspace.ts +52 -0
- package/lib/environment-BL1jddfB.js +0 -449
- package/lib/switch-fs-CAJpFY9C.js +0 -193
- package/lib/switch-fs-RrZtG2gv.js +0 -210
- package/lib/types/client/manager-button.d.ts +0 -32
- package/lib/types/client/ssh/mount.d.ts +0 -13
- package/lib/types/client/ssh/panel/HostsTab.d.ts +0 -10
- package/lib/types/client/ssh/panel/controller.d.ts +0 -23
- package/lib/types/client/ssh/sidebar-entry.d.ts +0 -25
- package/lib/types/client/workspace-gate.d.ts +0 -15
- package/lib/types/remote-runner.d.ts +0 -83
- package/lib/types/seam-state.d.ts +0 -69
- package/src/client/manager-button.tsx +0 -269
- package/src/client/ssh/mount.tsx +0 -83
- package/src/client/ssh/panel/HostsTab.tsx +0 -236
- package/src/client/ssh/panel/controller.ts +0 -48
- package/src/client/ssh/sidebar-entry.ts +0 -123
- package/src/client/workspace-gate.ts +0 -225
- package/src/remote-runner.ts +0 -201
- package/src/seam-state.ts +0 -185
package/src/ssh/vault.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
|
18
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
19
19
|
import { dirname, join } from 'node:path'
|
|
20
20
|
import { homedir } from 'node:os'
|
|
21
21
|
|
|
@@ -41,6 +41,8 @@ const LOCKOUT_FLOOR_MS = 30_000
|
|
|
41
41
|
const LOCKOUT_CAP_MS = 3_600_000
|
|
42
42
|
/** Fixed plaintext used to verify the unlock password. */
|
|
43
43
|
const VERIFIER_TEXT = 'dsh-hardssh:vault:verify:v1'
|
|
44
|
+
/** Minimum non-whitespace master-password length. */
|
|
45
|
+
export const MIN_MASTER_PASSWORD_LENGTH = 8
|
|
44
46
|
|
|
45
47
|
/** Types of secrets the vault stores. */
|
|
46
48
|
export type VaultPurpose = 'host.password' | 'host.passphrase'
|
|
@@ -83,11 +85,64 @@ interface VaultDocument {
|
|
|
83
85
|
secrets: VaultEntry[]
|
|
84
86
|
}
|
|
85
87
|
|
|
86
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* Directory holding the credential vault: `<home>/.dsh/ssh-secrets`.
|
|
90
|
+
*
|
|
91
|
+
* It IS inside `~/.dsh` (which the fs seam declares a local-infrastructure root
|
|
92
|
+
* so skills and plugin config stay readable), so its protection does NOT come
|
|
93
|
+
* from location — it comes from `SwitchFileSystem.deniedRoots` (fs.ts), which
|
|
94
|
+
* refuses this directory on every dispatch path, resolve to write, before any
|
|
95
|
+
* backend sees it.
|
|
96
|
+
*
|
|
97
|
+
* Residual risk, stated plainly: a command that runs on THIS machine as the
|
|
98
|
+
* same user (e.g. the client-side `pwsh` tool) can still read the file. What
|
|
99
|
+
* keeps the credential safe is that the file is encrypted (AES-256-GCM +
|
|
100
|
+
* scrypt) AND that environment auto-unlock is off by default — so a stolen
|
|
101
|
+
* ciphertext is only an offline scrypt target, not a usable credential.
|
|
102
|
+
*/
|
|
103
|
+
export function vaultDirectory(): string {
|
|
104
|
+
return join(homedir(), '.dsh', 'ssh-secrets')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Vault file location: <home>/.dsh/ssh-secrets/dsh-ssh-vault.json. */
|
|
87
108
|
export function vaultPath(): string {
|
|
109
|
+
return join(vaultDirectory(), 'dsh-ssh-vault.json')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Pre-relocation vault location (<home>/.dsh/dsh-ssh-vault.json). */
|
|
113
|
+
export function legacyVaultPath(): string {
|
|
88
114
|
return join(homedir(), '.dsh', 'dsh-ssh-vault.json')
|
|
89
115
|
}
|
|
90
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Move a pre-relocation vault into {@link vaultDirectory} on first use.
|
|
119
|
+
*
|
|
120
|
+
* Never destructive: the new location wins when both exist, and a failed move
|
|
121
|
+
* falls back to copying so the source file is left in place.
|
|
122
|
+
*
|
|
123
|
+
* @param target - destination path (defaults to {@link vaultPath}).
|
|
124
|
+
* @param legacy - source path (defaults to {@link legacyVaultPath}).
|
|
125
|
+
* @returns true when a move/copy happened.
|
|
126
|
+
*/
|
|
127
|
+
export function migrateLegacyVault(target: string = vaultPath(), legacy: string = legacyVaultPath()): boolean {
|
|
128
|
+
if (target === legacy) return false
|
|
129
|
+
if (existsSync(target) || !existsSync(legacy)) return false
|
|
130
|
+
try {
|
|
131
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 })
|
|
132
|
+
try {
|
|
133
|
+
renameSync(legacy, target)
|
|
134
|
+
} catch {
|
|
135
|
+
// Cross-device or locked source: copy the bytes and keep the original.
|
|
136
|
+
const contents = readFileSync(legacy)
|
|
137
|
+
writeFileSync(target, contents, { mode: 0o600 })
|
|
138
|
+
}
|
|
139
|
+
return true
|
|
140
|
+
} catch {
|
|
141
|
+
// Best-effort: an unmigratable legacy file still loads from its old path.
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
91
146
|
/** AAD for one entry. */
|
|
92
147
|
function entryAad(purpose: VaultPurpose, alias: string): string {
|
|
93
148
|
return `dsh-hardssh:v1:${purpose}:${alias}`
|
|
@@ -143,35 +198,45 @@ export interface RevealedSecret {
|
|
|
143
198
|
*/
|
|
144
199
|
export class Vault {
|
|
145
200
|
private readonly path: string
|
|
146
|
-
private readonly passwordProvider: (() => Promise<string | undefined>) | undefined
|
|
147
201
|
private document: VaultDocument = emptyDocument()
|
|
148
202
|
private unlockedKey: Buffer | undefined
|
|
149
203
|
private lockTimer: ReturnType<typeof setTimeout> | undefined
|
|
204
|
+
private readonly idleLockMs: number
|
|
150
205
|
private readonly leaked = new Set<string>()
|
|
206
|
+
/** Whether `DSH_CREDENTIAL_PASSWORD` may unlock this instance at load. */
|
|
207
|
+
private readonly allowEnvUnlock: boolean
|
|
208
|
+
/** Single-instance mutation queue. Every operation that can change the
|
|
209
|
+
* in-memory document or persist it runs in invocation order. The tail is
|
|
210
|
+
* always recovered so one rejected mutation never poisons later work. */
|
|
211
|
+
private mutationTail: Promise<void> = Promise.resolve()
|
|
151
212
|
|
|
152
213
|
constructor(
|
|
153
214
|
filePath?: string,
|
|
154
215
|
options?: {
|
|
155
|
-
/** Session password source (loopback unlock route passes a password). */
|
|
156
|
-
passwordProvider?: () => Promise<string | undefined>
|
|
157
216
|
/** Vault idle auto-lock (ms); 0 disables (default 30 min). */
|
|
158
217
|
idleLockMs?: number
|
|
218
|
+
/**
|
|
219
|
+
* Allow unlocking from `DSH_CREDENTIAL_PASSWORD` at construction.
|
|
220
|
+
* OFF by default: that variable is visible to anything running as the
|
|
221
|
+
* same user — including an agent session — so auto-unlock is an explicit
|
|
222
|
+
* opt-in (`vaultAutoUnlock: 'env'` in the plugin config), not a default.
|
|
223
|
+
*/
|
|
224
|
+
allowEnvUnlock?: boolean
|
|
159
225
|
},
|
|
160
226
|
) {
|
|
227
|
+
if (filePath === undefined) migrateLegacyVault()
|
|
161
228
|
this.path = filePath ?? vaultPath()
|
|
162
|
-
this.
|
|
229
|
+
this.allowEnvUnlock = options?.allowEnvUnlock === true
|
|
163
230
|
this.load()
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
const env = process.env.DSH_CREDENTIAL_PASSWORD
|
|
231
|
+
// Opt-in auto-unlock from DSH_CREDENTIAL_PASSWORD (headless / unattended
|
|
232
|
+
// agents that deliberately enable it).
|
|
233
|
+
const env = this.allowEnvUnlock ? process.env.DSH_CREDENTIAL_PASSWORD : undefined
|
|
167
234
|
if (env !== undefined && env !== '') {
|
|
168
235
|
void this.tryEnvUnlock(env)
|
|
169
236
|
}
|
|
170
237
|
const idle = options?.idleLockMs ?? 30 * 60_000
|
|
171
|
-
if (idle
|
|
172
|
-
|
|
173
|
-
this.lockTimer.unref?.()
|
|
174
|
-
}
|
|
238
|
+
if (!Number.isSafeInteger(idle) || idle < 0) throw new VaultError('VAULT_CONFIG', 'idleLockMs must be a non-negative safe integer')
|
|
239
|
+
this.idleLockMs = idle
|
|
175
240
|
}
|
|
176
241
|
|
|
177
242
|
// ------------------------------------------------------------- internals
|
|
@@ -192,12 +257,19 @@ export class Vault {
|
|
|
192
257
|
}
|
|
193
258
|
}
|
|
194
259
|
|
|
195
|
-
private save(): void {
|
|
260
|
+
private save(): void { this.saveDocument(this.document) }
|
|
261
|
+
|
|
262
|
+
private saveDocument(document: VaultDocument): void {
|
|
196
263
|
const dir = dirname(this.path)
|
|
197
264
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
198
|
-
const tmp = this.path +
|
|
199
|
-
|
|
200
|
-
|
|
265
|
+
const tmp = this.path + `.tmp-${process.pid}-${randomBytes(6).toString('hex')}`
|
|
266
|
+
try {
|
|
267
|
+
writeFileSync(tmp, JSON.stringify(document, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
268
|
+
renameSync(tmp, this.path)
|
|
269
|
+
} catch (error) {
|
|
270
|
+
try { unlinkSync(tmp) } catch { /* preserve the persistence error */ }
|
|
271
|
+
throw error
|
|
272
|
+
}
|
|
201
273
|
}
|
|
202
274
|
|
|
203
275
|
private deriveKey(password: string, kdf: VaultKdfParams): Promise<Buffer> {
|
|
@@ -237,6 +309,10 @@ export class Vault {
|
|
|
237
309
|
|
|
238
310
|
private async verifyPassword(key: Buffer, password: string): Promise<void> {
|
|
239
311
|
const doc = this.document
|
|
312
|
+
const previousMeta = { ...doc.meta }
|
|
313
|
+
const persistFailure = (): void => {
|
|
314
|
+
try { this.save() } catch (error) { doc.meta = previousMeta; throw error }
|
|
315
|
+
}
|
|
240
316
|
const verifier = doc.verifier
|
|
241
317
|
try {
|
|
242
318
|
const plain = this.decryptEntryBuffer(key, verifier.aad, verifier.nonceB64, verifier.ciphertextB64, verifier.tagB64)
|
|
@@ -247,10 +323,10 @@ export class Vault {
|
|
|
247
323
|
if (doc.meta.attempts >= LOCKOUT_THRESHOLD) {
|
|
248
324
|
const backoff = backoffFor(doc.meta.attempts)
|
|
249
325
|
doc.meta.lockedUntil = Date.now() + backoff
|
|
250
|
-
|
|
326
|
+
persistFailure()
|
|
251
327
|
throw new VaultLockoutError(`vault locked after ${doc.meta.attempts} failed attempts — retry after ${Math.ceil(backoff / 1000)}s`, backoff)
|
|
252
328
|
}
|
|
253
|
-
|
|
329
|
+
persistFailure()
|
|
254
330
|
throw new VaultAuthError(`wrong vault password (${doc.meta.attempts}/${LOCKOUT_THRESHOLD} attempts used)`, LOCKOUT_THRESHOLD - doc.meta.attempts)
|
|
255
331
|
}
|
|
256
332
|
}
|
|
@@ -283,6 +359,30 @@ export class Vault {
|
|
|
283
359
|
return this.unlockedKey
|
|
284
360
|
}
|
|
285
361
|
|
|
362
|
+
private validateMasterPassword(password: string): void {
|
|
363
|
+
if (password.trim().length < MIN_MASTER_PASSWORD_LENGTH) {
|
|
364
|
+
throw new VaultError('VAULT_PASSWORD_WEAK', `vault password must contain at least ${MIN_MASTER_PASSWORD_LENGTH} non-whitespace characters`)
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Successful credential activity extends the idle lock deadline. */
|
|
369
|
+
private touch(): void {
|
|
370
|
+
if (this.lockTimer !== undefined) clearTimeout(this.lockTimer)
|
|
371
|
+
this.lockTimer = undefined
|
|
372
|
+
if (this.unlockedKey === undefined || this.idleLockMs === 0) return
|
|
373
|
+
this.lockTimer = setTimeout(() => { this.lock() }, this.idleLockMs)
|
|
374
|
+
this.lockTimer.unref?.()
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private enqueueMutation<T>(mutation: () => Promise<T> | T): Promise<T> {
|
|
378
|
+
const result = this.mutationTail.then(mutation)
|
|
379
|
+
this.mutationTail = result.then(
|
|
380
|
+
() => undefined,
|
|
381
|
+
() => undefined,
|
|
382
|
+
)
|
|
383
|
+
return result
|
|
384
|
+
}
|
|
385
|
+
|
|
286
386
|
private async tryEnvUnlock(password: string): Promise<void> {
|
|
287
387
|
try {
|
|
288
388
|
await this.unlock(password)
|
|
@@ -298,60 +398,92 @@ export class Vault {
|
|
|
298
398
|
const hasEntries = this.document.secrets.length > 0
|
|
299
399
|
return {
|
|
300
400
|
locked: this.unlockedKey === undefined || hasEntries === false && this.document.verifier.ciphertextB64 === '',
|
|
301
|
-
mode: process.env.DSH_CREDENTIAL_PASSWORD !== undefined ? 'env' : 'password',
|
|
401
|
+
mode: this.allowEnvUnlock && process.env.DSH_CREDENTIAL_PASSWORD !== undefined ? 'env' : 'password',
|
|
302
402
|
entries: this.document.secrets.length,
|
|
303
403
|
}
|
|
304
404
|
}
|
|
305
405
|
|
|
306
406
|
/** Unlock with a password (loopback route / env). Persists lockout on failure. */
|
|
307
407
|
async unlock(password: string): Promise<void> {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
408
|
+
return this.enqueueMutation(async () => {
|
|
409
|
+
this.validateMasterPassword(password)
|
|
410
|
+
if (this.unlockedKey !== undefined) { this.touch(); return }
|
|
411
|
+
this.checkLockout()
|
|
412
|
+
const doc = this.document
|
|
413
|
+
if (doc.verifier.ciphertextB64 === '') {
|
|
414
|
+
// First unlock: create the verifier with this password.
|
|
415
|
+
const salt = randomBytes(SALT_BYTES)
|
|
416
|
+
const kdf: VaultKdfParams = { algorithm: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, saltB64: salt.toString('base64') }
|
|
417
|
+
const key = await this.deriveKey(password, kdf)
|
|
418
|
+
const box = this.encryptEntryBuffer(key, entryAad('host.password', 'vault-verify'), Buffer.from(VERIFIER_TEXT, 'utf8'))
|
|
419
|
+
const next: VaultDocument = {
|
|
420
|
+
...doc,
|
|
421
|
+
kdf,
|
|
422
|
+
verifier: { ...box, aad: entryAad('host.password', 'vault-verify') },
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
this.saveDocument(next)
|
|
426
|
+
} catch (error) {
|
|
427
|
+
key.fill(0)
|
|
428
|
+
throw error
|
|
429
|
+
}
|
|
430
|
+
this.document = next
|
|
431
|
+
this.unlockedKey = key
|
|
432
|
+
this.touch()
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
const key = await this.deriveKey(password, doc.kdf)
|
|
436
|
+
try {
|
|
437
|
+
await this.verifyPassword(key, password)
|
|
438
|
+
} catch (error) {
|
|
439
|
+
key.fill(0)
|
|
440
|
+
throw error
|
|
441
|
+
}
|
|
319
442
|
this.unlockedKey = key
|
|
320
|
-
this.
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
const key = await this.deriveKey(password, doc.kdf)
|
|
324
|
-
await this.verifyPassword(key, password)
|
|
325
|
-
this.unlockedKey = key
|
|
443
|
+
this.touch()
|
|
444
|
+
})
|
|
326
445
|
}
|
|
327
446
|
|
|
328
447
|
/** Lock: drop the in-memory key. JS strings cannot be zeroized; the Buffer
|
|
329
448
|
* key is wiped. */
|
|
330
449
|
lock(): void {
|
|
450
|
+
if (this.lockTimer !== undefined) clearTimeout(this.lockTimer)
|
|
451
|
+
this.lockTimer = undefined
|
|
331
452
|
if (this.unlockedKey !== undefined) {
|
|
332
453
|
this.unlockedKey.fill(0)
|
|
333
454
|
this.unlockedKey = undefined
|
|
334
455
|
}
|
|
456
|
+
// JS strings cannot be zeroized, but dropping every reference bounds the
|
|
457
|
+
// leak-guard plaintext lifetime to one unlocked activity session.
|
|
458
|
+
this.leaked.clear()
|
|
335
459
|
}
|
|
336
460
|
|
|
337
461
|
/** Store one secret, returning a random ref. Requires unlock. */
|
|
338
462
|
async store(purpose: VaultPurpose, alias: string, secret: string): Promise<string> {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
463
|
+
return this.enqueueMutation(() => {
|
|
464
|
+
const key = this.requireKey()
|
|
465
|
+
const ref = 'vlt_' + randomBytes(16).toString('hex')
|
|
466
|
+
const aad = entryAad(purpose, alias)
|
|
467
|
+
const box = this.encryptEntryBuffer(key, aad, Buffer.from(secret, 'utf8'))
|
|
468
|
+
const data = `${box.nonceB64}${box.ciphertextB64}${box.tagB64}`
|
|
469
|
+
const entry: VaultEntry = {
|
|
470
|
+
ref,
|
|
471
|
+
purpose,
|
|
472
|
+
alias,
|
|
473
|
+
...box,
|
|
474
|
+
aad,
|
|
475
|
+
sha3: sha3_256Hex(data),
|
|
476
|
+
}
|
|
477
|
+
this.document.secrets.push(entry)
|
|
478
|
+
try {
|
|
479
|
+
this.save()
|
|
480
|
+
} catch (error) {
|
|
481
|
+
this.document.secrets.pop()
|
|
482
|
+
throw error
|
|
483
|
+
}
|
|
484
|
+
this.touch()
|
|
485
|
+
return ref
|
|
486
|
+
})
|
|
355
487
|
}
|
|
356
488
|
|
|
357
489
|
/** Reveal one secret (unlock required); registers it for redaction. */
|
|
@@ -368,53 +500,75 @@ export class Vault {
|
|
|
368
500
|
const text = plain.toString('utf8')
|
|
369
501
|
plain.fill(0)
|
|
370
502
|
this.leaked.add(text)
|
|
503
|
+
this.touch()
|
|
371
504
|
return text
|
|
372
505
|
}
|
|
373
506
|
|
|
374
507
|
/** Delete one secret (host delete / cleanup). */
|
|
375
508
|
async remove(ref: string): Promise<void> {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
509
|
+
return this.enqueueMutation(() => {
|
|
510
|
+
this.requireKey()
|
|
511
|
+
const before = this.document.secrets
|
|
512
|
+
const next = before.filter(entry => entry.ref !== ref)
|
|
513
|
+
if (next.length !== before.length) {
|
|
514
|
+
this.document.secrets = next
|
|
515
|
+
try { this.save() } catch (error) { this.document.secrets = before; throw error }
|
|
516
|
+
}
|
|
517
|
+
this.touch()
|
|
518
|
+
})
|
|
379
519
|
}
|
|
380
520
|
|
|
381
521
|
/** Rekey with a new password: decrypt all, re-encrypt under a fresh KDF. */
|
|
382
522
|
async rekey(newPassword: string): Promise<void> {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
523
|
+
return this.enqueueMutation(async () => {
|
|
524
|
+
this.validateMasterPassword(newPassword)
|
|
525
|
+
const key = this.requireKey()
|
|
526
|
+
const doc = this.document
|
|
527
|
+
// Decrypt everything with the current key.
|
|
528
|
+
const plaintexts: Array<{ purpose: VaultPurpose; alias: string; value: string }> = []
|
|
529
|
+
for (const entry of doc.secrets) {
|
|
530
|
+
const plain = this.decryptEntryBuffer(key, entry.aad, entry.nonceB64, entry.ciphertextB64, entry.tagB64)
|
|
531
|
+
plaintexts.push({ purpose: entry.purpose, alias: entry.alias, value: plain.toString('utf8') })
|
|
532
|
+
plain.fill(0)
|
|
533
|
+
}
|
|
534
|
+
// Fresh KDF + verifier under the new password.
|
|
535
|
+
const salt = randomBytes(SALT_BYTES)
|
|
536
|
+
const kdf: VaultKdfParams = { algorithm: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, saltB64: salt.toString('base64') }
|
|
537
|
+
const newKey = await this.deriveKey(newPassword, kdf)
|
|
538
|
+
try {
|
|
539
|
+
const box = this.encryptEntryBuffer(newKey, entryAad('host.password', 'vault-verify'), Buffer.from(VERIFIER_TEXT, 'utf8'))
|
|
540
|
+
const secrets = plaintexts.map(({ purpose, alias, value }, index) => {
|
|
541
|
+
const aad = entryAad(purpose, alias)
|
|
542
|
+
const b = this.encryptEntryBuffer(newKey, aad, Buffer.from(value, 'utf8'))
|
|
543
|
+
const data = `${b.nonceB64}${b.ciphertextB64}${b.tagB64}`
|
|
544
|
+
return {
|
|
545
|
+
ref: rekeyOriginalRef(doc.secrets[index]),
|
|
546
|
+
purpose,
|
|
547
|
+
alias,
|
|
548
|
+
...b,
|
|
549
|
+
aad,
|
|
550
|
+
sha3: sha3_256Hex(data),
|
|
551
|
+
} satisfies VaultEntry
|
|
552
|
+
})
|
|
553
|
+
const next: VaultDocument = {
|
|
554
|
+
...doc,
|
|
555
|
+
kdf,
|
|
556
|
+
verifier: { ...box, aad: entryAad('host.password', 'vault-verify') },
|
|
557
|
+
meta: { attempts: 0, lockedUntil: 0 },
|
|
558
|
+
secrets,
|
|
559
|
+
}
|
|
560
|
+
this.saveDocument(next)
|
|
561
|
+
key.fill(0)
|
|
562
|
+
this.document = next
|
|
563
|
+
this.unlockedKey = newKey
|
|
564
|
+
this.touch()
|
|
565
|
+
} catch (error) {
|
|
566
|
+
newKey.fill(0)
|
|
567
|
+
throw error
|
|
568
|
+
} finally {
|
|
569
|
+
for (const item of plaintexts) item.value = ''
|
|
570
|
+
}
|
|
412
571
|
})
|
|
413
|
-
this.unlockedKey?.fill(0)
|
|
414
|
-
this.unlockedKey = newKey
|
|
415
|
-
doc.meta.attempts = 0
|
|
416
|
-
doc.meta.lockedUntil = 0
|
|
417
|
-
this.save()
|
|
418
572
|
}
|
|
419
573
|
|
|
420
574
|
/** Redact every leaked secret value in `text` (Leak Guard light). */
|
|
@@ -429,7 +583,7 @@ export class Vault {
|
|
|
429
583
|
|
|
430
584
|
/** Dispose: wipe the key and stop the idle timer. */
|
|
431
585
|
dispose(): void {
|
|
432
|
-
if (this.lockTimer !== undefined)
|
|
586
|
+
if (this.lockTimer !== undefined) clearTimeout(this.lockTimer)
|
|
433
587
|
this.lock()
|
|
434
588
|
}
|
|
435
589
|
}
|
package/src/subprocess.ts
CHANGED
|
@@ -1,71 +1,88 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The `ctx.subprocess` switch row: provides the
|
|
3
|
-
* facade in the host scope. The local runtime is mounted in an
|
|
4
|
-
* child scope;
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
import type {
|
|
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
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The `ctx.subprocess` switch row: provides the generic WorkspaceCore-routing
|
|
3
|
+
* subprocess facade in the host scope. The local runtime is mounted in an
|
|
4
|
+
* isolated child scope; each spawn resolves by cwd through the same workspace
|
|
5
|
+
* router used by the filesystem seam.
|
|
6
|
+
*
|
|
7
|
+
* A bound connection supplies `workspace.process`. Before the core is ready,
|
|
8
|
+
* or for an unowned cwd beneath the managed SSH anchor root, spawning fails
|
|
9
|
+
* closed rather than executing on the client machine.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-hardssh/subprocess
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
15
|
+
import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
|
|
16
|
+
import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
|
|
17
|
+
import { anchorRoot, isPathUnderAnchor } from './ledger.ts'
|
|
18
|
+
import { SwitchSubprocessRuntime } from './switch/switch-subprocess.ts'
|
|
19
|
+
import type { WorkspaceCore } from './runtime/workspace-core.ts'
|
|
20
|
+
import type { WorkspaceRecord } from './base/model.ts'
|
|
21
|
+
|
|
22
|
+
/** Stable cordis plugin name. */
|
|
23
|
+
export const name = 'hardssh-subprocess'
|
|
24
|
+
|
|
25
|
+
/** Workspace routing is exclusively provided by the generic core. */
|
|
26
|
+
export const inject = ['workspaceCore']
|
|
27
|
+
|
|
28
|
+
/** The record behind a router connection (sync snapshot lookup). */
|
|
29
|
+
function genericRecordFor(core: WorkspaceCore, id: string): WorkspaceRecord | undefined {
|
|
30
|
+
return core.ledger.snapshotSync().records.find(record => record.id === id)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Resolve the subprocess runtime owning a cwd, or undefined when local. */
|
|
34
|
+
export function genericSubprocessFor(
|
|
35
|
+
core: WorkspaceCore,
|
|
36
|
+
cwd: string | undefined,
|
|
37
|
+
reservedAnchorRoots: readonly string[],
|
|
38
|
+
): SubprocessRuntime | undefined {
|
|
39
|
+
if (!core.isReady()) {
|
|
40
|
+
if (cwd !== undefined && reservedAnchorRoots.some(root => isPathUnderAnchor(root, cwd))) {
|
|
41
|
+
throw new Error('subprocess-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)')
|
|
42
|
+
}
|
|
43
|
+
return undefined
|
|
44
|
+
}
|
|
45
|
+
const connection = core.router.fromAnchor(cwd)
|
|
46
|
+
if (connection === undefined) return undefined
|
|
47
|
+
const record = genericRecordFor(core, connection.workspaceId)
|
|
48
|
+
if (record === undefined) throw new Error(`subprocess-workspace: routed workspace '${connection.workspaceId}' is missing from the ledger`)
|
|
49
|
+
const runtime = connection.get('workspace.process') as SubprocessRuntime | undefined
|
|
50
|
+
if (runtime === undefined) throw new Error(`subprocess-workspace: workspace '${record.id}' provides no workspace.process capability`)
|
|
51
|
+
return runtime
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Mount the generic switching subprocess facade. */
|
|
55
|
+
export function apply(ctx: Context): void {
|
|
56
|
+
const localCtx = ctx.isolate('subprocess')
|
|
57
|
+
const localSubprocess = new LocalSubprocessRuntime(localCtx)
|
|
58
|
+
const ws = ctx.workspaceCore
|
|
59
|
+
const anchorRootDir = anchorRoot()
|
|
60
|
+
let warnedUnready = false
|
|
61
|
+
|
|
62
|
+
new SwitchSubprocessRuntime(ctx, {
|
|
63
|
+
local: localSubprocess,
|
|
64
|
+
// `undefined` means LOCAL. Returning the local runtime here instead would
|
|
65
|
+
// force the facade to identity-compare it against `deps.local`, and that
|
|
66
|
+
// comparison is not reliable for a container-provided service — it once
|
|
67
|
+
// made the client-search refusal fire for local sessions and broke
|
|
68
|
+
// glob/grep in every session.
|
|
69
|
+
worldFor: (cwd) => {
|
|
70
|
+
if (!ws.isReady()) {
|
|
71
|
+
if (cwd !== undefined && isPathUnderAnchor(anchorRootDir, cwd)) {
|
|
72
|
+
if (!warnedUnready) {
|
|
73
|
+
warnedUnready = true
|
|
74
|
+
console.warn('[dsh-hardssh] subprocess routing is not ready yet (workspace core still initializing or failed) — refusing to run beneath the workspace anchor root')
|
|
75
|
+
}
|
|
76
|
+
throw new Error('subprocess-ssh: workspace routing is unavailable while the generic workspace core is not ready (anchor path fails closed)')
|
|
77
|
+
}
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
const runtime = genericSubprocessFor(ws, cwd, [anchorRootDir])
|
|
81
|
+
if (runtime !== undefined) return runtime
|
|
82
|
+
if (cwd !== undefined && isPathUnderAnchor(anchorRootDir, cwd)) {
|
|
83
|
+
throw new Error(`subprocess-ssh: '${cwd}' is inside the workspace anchor root but no registered workspace owns it (fail closed)`)
|
|
84
|
+
}
|
|
85
|
+
return undefined
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
}
|