@tiphareth/dsh-hardssh 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/README.md +50 -0
  2. package/cordis.patch.yml +26 -0
  3. package/lib/client.js +17780 -0
  4. package/lib/environment-BL1jddfB.js +449 -0
  5. package/lib/fs.js +478 -0
  6. package/lib/index.js +6758 -0
  7. package/lib/subprocess.js +600 -0
  8. package/lib/switch-fs-CAJpFY9C.js +193 -0
  9. package/lib/switch-fs-RrZtG2gv.js +210 -0
  10. package/lib/types/backend.d.ts +108 -0
  11. package/lib/types/base/capability.d.ts +107 -0
  12. package/lib/types/base/index.d.ts +18 -0
  13. package/lib/types/base/ledger-router.d.ts +48 -0
  14. package/lib/types/base/ledger.d.ts +82 -0
  15. package/lib/types/base/model.d.ts +108 -0
  16. package/lib/types/base/namespace.d.ts +57 -0
  17. package/lib/types/base/plugin.d.ts +102 -0
  18. package/lib/types/base/registry.d.ts +60 -0
  19. package/lib/types/base/router.d.ts +40 -0
  20. package/lib/types/client/api.d.ts +85 -0
  21. package/lib/types/client/directory-flow.d.ts +82 -0
  22. package/lib/types/client/icons.d.ts +19 -0
  23. package/lib/types/client/index.d.ts +34 -0
  24. package/lib/types/client/locales.d.ts +104 -0
  25. package/lib/types/client/manager-button.d.ts +32 -0
  26. package/lib/types/client/migrate.d.ts +20 -0
  27. package/lib/types/client/ssh/api.d.ts +89 -0
  28. package/lib/types/client/ssh/apply.d.ts +23 -0
  29. package/lib/types/client/ssh/locales.d.ts +156 -0
  30. package/lib/types/client/ssh/mount.d.ts +13 -0
  31. package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
  32. package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
  33. package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
  34. package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
  35. package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
  36. package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
  37. package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
  38. package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
  39. package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
  40. package/lib/types/client/ssh/panel/controller.d.ts +23 -0
  41. package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
  42. package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
  43. package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
  44. package/lib/types/client/state.d.ts +32 -0
  45. package/lib/types/client/text.d.ts +11 -0
  46. package/lib/types/client/workspace-badges.d.ts +38 -0
  47. package/lib/types/client/workspace-gate.d.ts +15 -0
  48. package/lib/types/client-http.d.ts +15 -0
  49. package/lib/types/core.d.ts +43 -0
  50. package/lib/types/fs.d.ts +36 -0
  51. package/lib/types/host-http.d.ts +25 -0
  52. package/lib/types/index.d.ts +53 -0
  53. package/lib/types/ledger.d.ts +132 -0
  54. package/lib/types/protocol.d.ts +101 -0
  55. package/lib/types/providers/index.d.ts +19 -0
  56. package/lib/types/providers/local/provider.d.ts +58 -0
  57. package/lib/types/providers/ssh/provider.d.ts +112 -0
  58. package/lib/types/remote/environment.d.ts +29 -0
  59. package/lib/types/remote/output.d.ts +38 -0
  60. package/lib/types/remote/remote-fs.d.ts +69 -0
  61. package/lib/types/remote/remote-process.d.ts +41 -0
  62. package/lib/types/remote/remote-subprocess.d.ts +31 -0
  63. package/lib/types/remote/remote-terminal.d.ts +41 -0
  64. package/lib/types/remote-runner.d.ts +83 -0
  65. package/lib/types/remote-search.d.ts +50 -0
  66. package/lib/types/routes.d.ts +24 -0
  67. package/lib/types/runtime/workspace-core.d.ts +59 -0
  68. package/lib/types/seam-state.d.ts +69 -0
  69. package/lib/types/shell.d.ts +8 -0
  70. package/lib/types/ssh/connection/lease.d.ts +16 -0
  71. package/lib/types/ssh/connection/pool.d.ts +57 -0
  72. package/lib/types/ssh/engine.d.ts +434 -0
  73. package/lib/types/ssh/exec/output.d.ts +31 -0
  74. package/lib/types/ssh/known-hosts.d.ts +89 -0
  75. package/lib/types/ssh/plugin.d.ts +57 -0
  76. package/lib/types/ssh/protocol.d.ts +236 -0
  77. package/lib/types/ssh/routes.d.ts +44 -0
  78. package/lib/types/ssh/store.d.ts +107 -0
  79. package/lib/types/ssh/tools.d.ts +35 -0
  80. package/lib/types/ssh/transfer/progress.d.ts +14 -0
  81. package/lib/types/ssh/vault.d.ts +110 -0
  82. package/lib/types/subprocess.d.ts +32 -0
  83. package/lib/types/switch/switch-fs.d.ts +88 -0
  84. package/lib/types/switch/switch-subprocess.d.ts +34 -0
  85. package/lib/types/tools.d.ts +12 -0
  86. package/package.json +134 -0
  87. package/src/backend.ts +624 -0
  88. package/src/base/capability.ts +86 -0
  89. package/src/base/index.ts +18 -0
  90. package/src/base/ledger-router.ts +128 -0
  91. package/src/base/ledger.ts +299 -0
  92. package/src/base/model.ts +118 -0
  93. package/src/base/namespace.ts +102 -0
  94. package/src/base/plugin.ts +170 -0
  95. package/src/base/registry.ts +109 -0
  96. package/src/base/router.ts +43 -0
  97. package/src/client/api.ts +196 -0
  98. package/src/client/css-modules.d.ts +5 -0
  99. package/src/client/directory-flow.tsx +482 -0
  100. package/src/client/icons.tsx +50 -0
  101. package/src/client/index.ts +210 -0
  102. package/src/client/locales.ts +105 -0
  103. package/src/client/manager-button.tsx +269 -0
  104. package/src/client/migrate.ts +109 -0
  105. package/src/client/ssh/api.ts +411 -0
  106. package/src/client/ssh/apply.ts +50 -0
  107. package/src/client/ssh/locales.ts +322 -0
  108. package/src/client/ssh/mount.tsx +83 -0
  109. package/src/client/ssh/panel/ClusterTab.tsx +123 -0
  110. package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
  111. package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
  112. package/src/client/ssh/panel/HostsTab.tsx +236 -0
  113. package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
  114. package/src/client/ssh/panel/SshPanel.tsx +77 -0
  115. package/src/client/ssh/panel/TerminalTab.tsx +176 -0
  116. package/src/client/ssh/panel/TransferTab.tsx +232 -0
  117. package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
  118. package/src/client/ssh/panel/controller.ts +48 -0
  119. package/src/client/ssh/panel/helpers.ts +26 -0
  120. package/src/client/ssh/panel/panel.module.css +1006 -0
  121. package/src/client/ssh/panel/xterm.css.ts +2 -0
  122. package/src/client/ssh/sidebar-entry.ts +123 -0
  123. package/src/client/state.ts +99 -0
  124. package/src/client/text.ts +22 -0
  125. package/src/client/workspace-badges.ts +96 -0
  126. package/src/client/workspace-gate.ts +225 -0
  127. package/src/client/workspace.module.css +283 -0
  128. package/src/client-http.ts +45 -0
  129. package/src/core.ts +47 -0
  130. package/src/fs.ts +85 -0
  131. package/src/host-http.ts +74 -0
  132. package/src/index.ts +244 -0
  133. package/src/ledger.ts +416 -0
  134. package/src/protocol.ts +114 -0
  135. package/src/providers/index.ts +34 -0
  136. package/src/providers/local/provider.ts +179 -0
  137. package/src/providers/ssh/provider.ts +274 -0
  138. package/src/remote/environment.ts +123 -0
  139. package/src/remote/output.ts +142 -0
  140. package/src/remote/remote-fs.ts +532 -0
  141. package/src/remote/remote-process.ts +203 -0
  142. package/src/remote/remote-subprocess.ts +159 -0
  143. package/src/remote/remote-terminal.ts +141 -0
  144. package/src/remote-runner.ts +201 -0
  145. package/src/remote-search.ts +163 -0
  146. package/src/routes.ts +395 -0
  147. package/src/runtime/workspace-core.ts +154 -0
  148. package/src/seam-state.ts +185 -0
  149. package/src/shell.ts +10 -0
  150. package/src/ssh/connection/lease.ts +17 -0
  151. package/src/ssh/connection/pool.ts +275 -0
  152. package/src/ssh/engine.ts +1761 -0
  153. package/src/ssh/exec/output.ts +70 -0
  154. package/src/ssh/known-hosts.ts +214 -0
  155. package/src/ssh/plugin.ts +184 -0
  156. package/src/ssh/protocol.ts +227 -0
  157. package/src/ssh/routes.ts +892 -0
  158. package/src/ssh/store.ts +544 -0
  159. package/src/ssh/tools.ts +402 -0
  160. package/src/ssh/transfer/progress.ts +75 -0
  161. package/src/ssh/vault.ts +477 -0
  162. package/src/subprocess.ts +71 -0
  163. package/src/switch/switch-fs.ts +253 -0
  164. package/src/switch/switch-subprocess.ts +59 -0
  165. package/src/tools.ts +221 -0
@@ -0,0 +1,477 @@
1
+ /**
2
+ * Credential vault: secrets (SSH passwords, key passphrases) encrypted at
3
+ * rest with AES-256-GCM under a master key derived from a password via
4
+ * scrypt (Node built-in). Each entry uses an independent random nonce and
5
+ * binds its credential reference as GCM AAD; every ciphertext additionally
6
+ * carries a SHA3-256 digest for integrity self-checks. Brute-force lockout
7
+ * persists across restarts.
8
+ *
9
+ * The vault is deliberately dependency-free (node:crypto + fs only) and has
10
+ * no cordis coupling, matching the HostStore style. KDF parameters are stored
11
+ * in the file header so a later KDF upgrade never invalidates existing
12
+ * vaults (algorithm is a stable switch point).
13
+ *
14
+ * @module dsh-hardssh/vault
15
+ */
16
+
17
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
18
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
19
+ import { dirname, join } from 'node:path'
20
+ import { homedir } from 'node:os'
21
+
22
+ /** File format version. */
23
+ const FORMAT_VERSION = 1
24
+ /** Master key length (AES-256). */
25
+ const KEY_BYTES = 32
26
+ /** AES-GCM nonce length. */
27
+ const NONCE_BYTES = 12
28
+ /** AES-GCM auth tag length. */
29
+ const TAG_BYTES = 16
30
+ /** Password salt length (scrypt). */
31
+ const SALT_BYTES = 16
32
+ /** scrypt parameters (OWASP-ish baseline; stored per file). */
33
+ const SCRYPT_N = 131_072
34
+ const SCRYPT_R = 8
35
+ const SCRYPT_P = 1
36
+ /** Failed-unlock budget before lockout (persisted). */
37
+ const LOCKOUT_THRESHOLD = 5
38
+ /** Lockout backoff floor (ms). */
39
+ const LOCKOUT_FLOOR_MS = 30_000
40
+ /** Lockout backoff cap (ms). */
41
+ const LOCKOUT_CAP_MS = 3_600_000
42
+ /** Fixed plaintext used to verify the unlock password. */
43
+ const VERIFIER_TEXT = 'dsh-hardssh:vault:verify:v1'
44
+
45
+ /** Types of secrets the vault stores. */
46
+ export type VaultPurpose = 'host.password' | 'host.passphrase'
47
+
48
+ /** KDF parameters persisted in the file header. */
49
+ export interface VaultKdfParams {
50
+ algorithm: 'scrypt'
51
+ N: number
52
+ r: number
53
+ p: number
54
+ saltB64: string
55
+ }
56
+
57
+ /** Vault status (no secrets exposed). */
58
+ export interface VaultStatus {
59
+ locked: boolean
60
+ mode: 'env' | 'password'
61
+ entries: number
62
+ }
63
+
64
+ /** One encrypted secret entry. */
65
+ interface VaultEntry {
66
+ ref: string
67
+ purpose: VaultPurpose
68
+ alias: string
69
+ nonceB64: string
70
+ ciphertextB64: string
71
+ tagB64: string
72
+ /** GCM AAD (rebinds the entry to alias/purpose). */
73
+ aad: string
74
+ /** SHA3-256 of the ciphertext blob (integrity self-check). */
75
+ sha3: string
76
+ }
77
+
78
+ interface VaultDocument {
79
+ version: number
80
+ kdf: VaultKdfParams
81
+ verifier: { nonceB64: string; ciphertextB64: string; tagB64: string; aad: string }
82
+ meta: { attempts: number; lockedUntil: number }
83
+ secrets: VaultEntry[]
84
+ }
85
+
86
+ /** Default vault file location: <home>/.dsh/dsh-ssh-vault.json. */
87
+ export function vaultPath(): string {
88
+ return join(homedir(), '.dsh', 'dsh-ssh-vault.json')
89
+ }
90
+
91
+ /** AAD for one entry. */
92
+ function entryAad(purpose: VaultPurpose, alias: string): string {
93
+ return `dsh-hardssh:v1:${purpose}:${alias}`
94
+ }
95
+
96
+ /** SHA3-256 hex of a UTF-8 string. */
97
+ export function sha3_256Hex(text: string): string {
98
+ return createHash('sha3-256').update(text, 'utf8').digest('hex')
99
+ }
100
+
101
+ /** Errors raised by the vault. */
102
+ export class VaultError extends Error {
103
+ constructor(readonly code: string, message: string) {
104
+ super(message)
105
+ this.name = 'VaultError'
106
+ }
107
+ }
108
+
109
+ /** Unlocked-password or wrong-password errors. */
110
+ export class VaultLockedError extends VaultError {
111
+ constructor(message = 'vault is locked — unlock it before accessing secrets') {
112
+ super('VAULT_LOCKED', message)
113
+ this.name = 'VaultLockedError'
114
+ }
115
+ }
116
+
117
+ /** Wrong unlock password (with remaining attempts). */
118
+ export class VaultAuthError extends VaultError {
119
+ constructor(message: string, readonly remaining: number) {
120
+ super('VAULT_AUTH', message)
121
+ this.name = 'VaultAuthError'
122
+ }
123
+ }
124
+
125
+ /** Locked out due to repeated failures. */
126
+ export class VaultLockoutError extends VaultError {
127
+ constructor(message: string, readonly retryAfterMs: number) {
128
+ super('VAULT_LOCKOUT', message)
129
+ this.name = 'VaultLockoutError'
130
+ }
131
+ }
132
+
133
+ /** One revealed secret value, registered for output redaction. */
134
+ export interface RevealedSecret {
135
+ ref: string
136
+ value: string
137
+ }
138
+
139
+ /**
140
+ * The credential vault. Plain file I/O + node:crypto; no cordis dependency.
141
+ * Construction never requires a password (status/describe work locked); the
142
+ * master key is derived on unlock and cached in memory for the session.
143
+ */
144
+ export class Vault {
145
+ private readonly path: string
146
+ private readonly passwordProvider: (() => Promise<string | undefined>) | undefined
147
+ private document: VaultDocument = emptyDocument()
148
+ private unlockedKey: Buffer | undefined
149
+ private lockTimer: ReturnType<typeof setTimeout> | undefined
150
+ private readonly leaked = new Set<string>()
151
+
152
+ constructor(
153
+ filePath?: string,
154
+ options?: {
155
+ /** Session password source (loopback unlock route passes a password). */
156
+ passwordProvider?: () => Promise<string | undefined>
157
+ /** Vault idle auto-lock (ms); 0 disables (default 30 min). */
158
+ idleLockMs?: number
159
+ },
160
+ ) {
161
+ this.path = filePath ?? vaultPath()
162
+ this.passwordProvider = options?.passwordProvider
163
+ this.load()
164
+ // Auto-unlock from the DSH_CREDENTIAL_PASSWORD environment variable when
165
+ // present (headless / agent-friendly deterministic channel).
166
+ const env = process.env.DSH_CREDENTIAL_PASSWORD
167
+ if (env !== undefined && env !== '') {
168
+ void this.tryEnvUnlock(env)
169
+ }
170
+ const idle = options?.idleLockMs ?? 30 * 60_000
171
+ if (idle > 0) {
172
+ this.lockTimer = setInterval(() => { this.lock() }, idle)
173
+ this.lockTimer.unref?.()
174
+ }
175
+ }
176
+
177
+ // ------------------------------------------------------------- internals
178
+
179
+ private load(): void {
180
+ if (!existsSync(this.path)) {
181
+ this.document = emptyDocument()
182
+ return
183
+ }
184
+ try {
185
+ const parsed = JSON.parse(readFileSync(this.path, 'utf8')) as VaultDocument
186
+ if (!isDocument(parsed)) throw new Error('vault shape invalid')
187
+ this.document = parsed
188
+ } catch {
189
+ // Corrupt vault: rename aside, start empty (never silently overwrite).
190
+ try { renameSync(this.path, `${this.path}.corrupt-${Date.now()}`) } catch { /* best effort */ }
191
+ this.document = emptyDocument()
192
+ }
193
+ }
194
+
195
+ private save(): void {
196
+ const dir = dirname(this.path)
197
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
198
+ const tmp = this.path + '.tmp'
199
+ writeFileSync(tmp, JSON.stringify(this.document, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
200
+ renameSync(tmp, this.path)
201
+ }
202
+
203
+ private deriveKey(password: string, kdf: VaultKdfParams): Promise<Buffer> {
204
+ return new Promise((resolve, reject) => {
205
+ scrypt(
206
+ password,
207
+ Buffer.from(kdf.saltB64, 'base64'),
208
+ KEY_BYTES,
209
+ { N: kdf.N, r: kdf.r, p: kdf.p, maxmem: 512 * 1024 * 1024 },
210
+ (error, key) => {
211
+ if (error !== null) reject(error)
212
+ else resolve(key)
213
+ },
214
+ )
215
+ })
216
+ }
217
+
218
+ private lockoutRetryAfter(): number {
219
+ const doc = this.document
220
+ if (doc.meta.lockedUntil === 0) return 0
221
+ const remaining = doc.meta.lockedUntil - Date.now()
222
+ return remaining > 0 ? remaining : 0
223
+ }
224
+
225
+ private checkLockout(): void {
226
+ const doc = this.document
227
+ if (doc.meta.lockedUntil !== 0) {
228
+ const remaining = this.lockoutRetryAfter()
229
+ if (remaining > 0) {
230
+ throw new VaultLockoutError(`vault locked: too many failed attempts — retry after ${Math.ceil(remaining / 1000)}s`, remaining)
231
+ }
232
+ // Lockout expired: reset.
233
+ doc.meta.attempts = 0
234
+ doc.meta.lockedUntil = 0
235
+ }
236
+ }
237
+
238
+ private async verifyPassword(key: Buffer, password: string): Promise<void> {
239
+ const doc = this.document
240
+ const verifier = doc.verifier
241
+ try {
242
+ const plain = this.decryptEntryBuffer(key, verifier.aad, verifier.nonceB64, verifier.ciphertextB64, verifier.tagB64)
243
+ if (plain.toString('utf8') !== VERIFIER_TEXT) throw new Error('verifier mismatch')
244
+ plain.fill(0)
245
+ } catch {
246
+ doc.meta.attempts += 1
247
+ if (doc.meta.attempts >= LOCKOUT_THRESHOLD) {
248
+ const backoff = backoffFor(doc.meta.attempts)
249
+ doc.meta.lockedUntil = Date.now() + backoff
250
+ this.save()
251
+ throw new VaultLockoutError(`vault locked after ${doc.meta.attempts} failed attempts — retry after ${Math.ceil(backoff / 1000)}s`, backoff)
252
+ }
253
+ this.save()
254
+ throw new VaultAuthError(`wrong vault password (${doc.meta.attempts}/${LOCKOUT_THRESHOLD} attempts used)`, LOCKOUT_THRESHOLD - doc.meta.attempts)
255
+ }
256
+ }
257
+
258
+ private encryptEntryBuffer(key: Buffer, aad: string, plaintext: Buffer): { nonceB64: string; ciphertextB64: string; tagB64: string } {
259
+ const nonce = randomBytes(NONCE_BYTES)
260
+ const cipher = createCipheriv('aes-256-gcm', key, nonce)
261
+ cipher.setAAD(Buffer.from(aad, 'utf8'))
262
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()])
263
+ const tag = cipher.getAuthTag()
264
+ return {
265
+ nonceB64: nonce.toString('base64'),
266
+ ciphertextB64: ciphertext.toString('base64'),
267
+ tagB64: tag.toString('base64'),
268
+ }
269
+ }
270
+
271
+ private decryptEntryBuffer(key: Buffer, aad: string, nonceB64: string, ciphertextB64: string, tagB64: string): Buffer {
272
+ const nonce = Buffer.from(nonceB64, 'base64')
273
+ const tag = Buffer.from(tagB64, 'base64')
274
+ const ciphertext = Buffer.from(ciphertextB64, 'base64')
275
+ const decipher = createDecipheriv('aes-256-gcm', key, nonce)
276
+ decipher.setAAD(Buffer.from(aad, 'utf8'))
277
+ decipher.setAuthTag(tag)
278
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()])
279
+ }
280
+
281
+ private requireKey(): Buffer {
282
+ if (this.unlockedKey === undefined) throw new VaultLockedError()
283
+ return this.unlockedKey
284
+ }
285
+
286
+ private async tryEnvUnlock(password: string): Promise<void> {
287
+ try {
288
+ await this.unlock(password)
289
+ } catch {
290
+ // Env password wrong: stay locked; the loopback route can still unlock.
291
+ }
292
+ }
293
+
294
+ // ---------------------------------------------------------------- public
295
+
296
+ /** Current status (locked / mode / entry count) — safe for UIs. */
297
+ status(): VaultStatus {
298
+ const hasEntries = this.document.secrets.length > 0
299
+ return {
300
+ locked: this.unlockedKey === undefined || hasEntries === false && this.document.verifier.ciphertextB64 === '',
301
+ mode: process.env.DSH_CREDENTIAL_PASSWORD !== undefined ? 'env' : 'password',
302
+ entries: this.document.secrets.length,
303
+ }
304
+ }
305
+
306
+ /** Unlock with a password (loopback route / env). Persists lockout on failure. */
307
+ async unlock(password: string): Promise<void> {
308
+ if (this.unlockedKey !== undefined) return
309
+ this.checkLockout()
310
+ const doc = this.document
311
+ if (doc.verifier.ciphertextB64 === '') {
312
+ // First unlock: create the verifier with this password.
313
+ const salt = randomBytes(SALT_BYTES)
314
+ const kdf: VaultKdfParams = { algorithm: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, saltB64: salt.toString('base64') }
315
+ const key = await this.deriveKey(password, kdf)
316
+ doc.kdf = kdf
317
+ const box = this.encryptEntryBuffer(key, entryAad('host.password', 'vault-verify'), Buffer.from(VERIFIER_TEXT, 'utf8'))
318
+ doc.verifier = { ...box, aad: entryAad('host.password', 'vault-verify') }
319
+ this.unlockedKey = key
320
+ this.save()
321
+ return
322
+ }
323
+ const key = await this.deriveKey(password, doc.kdf)
324
+ await this.verifyPassword(key, password)
325
+ this.unlockedKey = key
326
+ }
327
+
328
+ /** Lock: drop the in-memory key. JS strings cannot be zeroized; the Buffer
329
+ * key is wiped. */
330
+ lock(): void {
331
+ if (this.unlockedKey !== undefined) {
332
+ this.unlockedKey.fill(0)
333
+ this.unlockedKey = undefined
334
+ }
335
+ }
336
+
337
+ /** Store one secret, returning a random ref. Requires unlock. */
338
+ async store(purpose: VaultPurpose, alias: string, secret: string): Promise<string> {
339
+ const key = this.requireKey()
340
+ const ref = 'vlt_' + randomBytes(16).toString('hex')
341
+ const aad = entryAad(purpose, alias)
342
+ const box = this.encryptEntryBuffer(key, aad, Buffer.from(secret, 'utf8'))
343
+ const data = `${box.nonceB64}${box.ciphertextB64}${box.tagB64}`
344
+ const entry: VaultEntry = {
345
+ ref,
346
+ purpose,
347
+ alias,
348
+ ...box,
349
+ aad,
350
+ sha3: sha3_256Hex(data),
351
+ }
352
+ this.document.secrets.push(entry)
353
+ this.save()
354
+ return ref
355
+ }
356
+
357
+ /** Reveal one secret (unlock required); registers it for redaction. */
358
+ async reveal(ref: string): Promise<string> {
359
+ const key = this.requireKey()
360
+ const entry = this.document.secrets.find(candidate => candidate.ref === ref)
361
+ if (entry === undefined) throw new VaultError('VAULT_MISSING', `no secret for ref '${ref}'`)
362
+ // Integrity self-check: SHA3 of the ciphertext must match.
363
+ const data = `${entry.nonceB64}${entry.ciphertextB64}${entry.tagB64}`
364
+ if (sha3_256Hex(data) !== entry.sha3) {
365
+ throw new VaultError('VAULT_CORRUPTED', `entry '${ref}' failed its SHA3-256 integrity check`)
366
+ }
367
+ const plain = this.decryptEntryBuffer(key, entry.aad, entry.nonceB64, entry.ciphertextB64, entry.tagB64)
368
+ const text = plain.toString('utf8')
369
+ plain.fill(0)
370
+ this.leaked.add(text)
371
+ return text
372
+ }
373
+
374
+ /** Delete one secret (host delete / cleanup). */
375
+ async remove(ref: string): Promise<void> {
376
+ const before = this.document.secrets.length
377
+ this.document.secrets = this.document.secrets.filter(entry => entry.ref !== ref)
378
+ if (this.document.secrets.length !== before) this.save()
379
+ }
380
+
381
+ /** Rekey with a new password: decrypt all, re-encrypt under a fresh KDF. */
382
+ async rekey(newPassword: string): Promise<void> {
383
+ const key = this.requireKey()
384
+ const doc = this.document
385
+ // Decrypt everything with the current key.
386
+ const plaintexts: Array<{ purpose: VaultPurpose; alias: string; value: string }> = []
387
+ for (const entry of doc.secrets) {
388
+ const plain = this.decryptEntryBuffer(key, entry.aad, entry.nonceB64, entry.ciphertextB64, entry.tagB64)
389
+ plaintexts.push({ purpose: entry.purpose, alias: entry.alias, value: plain.toString('utf8') })
390
+ plain.fill(0)
391
+ }
392
+ // Fresh KDF + verifier under the new password.
393
+ const salt = randomBytes(SALT_BYTES)
394
+ const kdf: VaultKdfParams = { algorithm: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, saltB64: salt.toString('base64') }
395
+ const newKey = await this.deriveKey(newPassword, kdf)
396
+ doc.kdf = kdf
397
+ const box = this.encryptEntryBuffer(newKey, entryAad('host.password', 'vault-verify'), Buffer.from(VERIFIER_TEXT, 'utf8'))
398
+ doc.verifier = { ...box, aad: entryAad('host.password', 'vault-verify') }
399
+ doc.secrets = plaintexts.map(({ purpose, alias, value }, index) => {
400
+ const aad = entryAad(purpose, alias)
401
+ const b = this.encryptEntryBuffer(newKey, aad, Buffer.from(value, 'utf8'))
402
+ const data = `${b.nonceB64}${b.ciphertextB64}${b.tagB64}`
403
+ return {
404
+ // Preserve the original ref: SshHostEntry.auth.secretRef persists it.
405
+ ref: rekeyOriginalRef(this.document.secrets[index]),
406
+ purpose,
407
+ alias,
408
+ ...b,
409
+ aad,
410
+ sha3: sha3_256Hex(data),
411
+ } satisfies VaultEntry
412
+ })
413
+ this.unlockedKey?.fill(0)
414
+ this.unlockedKey = newKey
415
+ doc.meta.attempts = 0
416
+ doc.meta.lockedUntil = 0
417
+ this.save()
418
+ }
419
+
420
+ /** Redact every leaked secret value in `text` (Leak Guard light). */
421
+ redact(text: string): string {
422
+ if (this.leaked.size === 0) return text
423
+ let out = text
424
+ for (const value of this.leaked) {
425
+ if (value.length > 0) out = out.split(value).join('[REDACTED]')
426
+ }
427
+ return out
428
+ }
429
+
430
+ /** Dispose: wipe the key and stop the idle timer. */
431
+ dispose(): void {
432
+ if (this.lockTimer !== undefined) clearInterval(this.lockTimer)
433
+ this.lock()
434
+ }
435
+ }
436
+
437
+ /** Empty vault document. */
438
+ function emptyDocument(): VaultDocument {
439
+ return {
440
+ version: FORMAT_VERSION,
441
+ kdf: { algorithm: 'scrypt', N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, saltB64: '' },
442
+ verifier: { nonceB64: '', ciphertextB64: '', tagB64: '', aad: '' },
443
+ meta: { attempts: 0, lockedUntil: 0 },
444
+ secrets: [],
445
+ }
446
+ }
447
+
448
+ /** Lockout backoff: exponential, floored, capped. */
449
+ function backoffFor(attempts: number): number {
450
+ const exponent = Math.min(attempts - LOCKOUT_THRESHOLD, 6)
451
+ const backoff = LOCKOUT_FLOOR_MS * Math.pow(2, exponent)
452
+ return Math.min(backoff, LOCKOUT_CAP_MS)
453
+ }
454
+
455
+ /** Guard: a parsed JSON value is a valid vault document. */
456
+ function isDocument(value: unknown): value is VaultDocument {
457
+ if (typeof value !== 'object' || value === null) return false
458
+ const doc = value as Record<string, unknown>
459
+ return typeof doc.version === 'number'
460
+ && typeof doc.kdf === 'object' && doc.kdf !== null
461
+ && typeof (doc.kdf as Record<string, unknown>).algorithm === 'string'
462
+ && typeof doc.verifier === 'object' && doc.verifier !== null
463
+ && typeof doc.meta === 'object' && doc.meta !== null
464
+ && Array.isArray(doc.secrets)
465
+ }
466
+
467
+ /** Constant-time helper (kept for completeness; GCM auth is the real gate). */
468
+ export function secretsEqual(a: string, b: string): boolean {
469
+ const bufA = Buffer.from(a)
470
+ const bufB = Buffer.from(b)
471
+ return bufA.length === bufB.length && timingSafeEqual(bufA, bufB)
472
+ }
473
+
474
+ /** The ref to reuse for the index-th entry during rekey (preserves identity). */
475
+ function rekeyOriginalRef(entry: VaultEntry | undefined): string {
476
+ return entry?.ref ?? 'vlt_' + randomBytes(16).toString('hex')
477
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The `ctx.subprocess` switch row: provides the workspace-routing subprocess
3
+ * facade in the host scope. The local runtime is mounted in an isolated
4
+ * child scope; every SSH-bound workspace record gets its OWN remote runtime
5
+ * bound to that record's alias + remote root; the facade routes each spawn by
6
+ * its cwd. The facade auto-provides `subprocess` here because the plain
7
+ * subprocess row is disabled by the profile patch.
8
+ *
9
+ * Routing and per-record remote runtimes are owned by the SHARED seam state
10
+ * (hardsshCore.seams): the row binds its per-record runtime factory and reads
11
+ * the state on every spawn. The seam state re-applies the ledger snapshot
12
+ * synchronously on every commit, so creating or removing an SSH workspace
13
+ * takes effect without a plugin restart and the fs/subprocess seams can never
14
+ * disagree. Before the initial ledger load finishes, routing degrades to
15
+ * local with a one-time warning for anchor-root paths.
16
+ *
17
+ * @module dsh-hardssh/subprocess
18
+ */
19
+
20
+ import type { Context } from '@deepseek-ai/cordis'
21
+ import { LocalSubprocessRuntime } from '@deepseek-ai/dsh-subprocess-local'
22
+ import type { HardsshCore } from './core.ts'
23
+ import { isPathUnderAnchor } from './ledger.ts'
24
+ import type { WorkspaceState } from './protocol.ts'
25
+ import { SshSubprocessRuntime } from './remote/remote-subprocess.ts'
26
+ import { SwitchSubprocessRuntime } from './switch/switch-subprocess.ts'
27
+
28
+ declare module '@deepseek-ai/cordis' {
29
+ interface Context {
30
+ hardsshCore: HardsshCore
31
+ }
32
+ }
33
+
34
+ /** Stable cordis plugin name. */
35
+ export const name = 'hardssh-subprocess'
36
+
37
+ /** Services required: the shared workspace core (mode store + engine). */
38
+ export const inject = ['hardsshCore']
39
+
40
+ /** A remote runtime fixed to one SSH workspace's alias + remote root. */
41
+ function fixedRemoteState(alias: string, remoteRoot: string): () => WorkspaceState {
42
+ return () => ({ mode: 'remote' as const, alias, remoteRoot })
43
+ }
44
+
45
+ /** Mount the switching subprocess facade. */
46
+ export function apply(ctx: Context): void {
47
+ const core = ctx.hardsshCore
48
+
49
+ // Local runtime in an isolated scope (its `subprocess` provide shadows only
50
+ // below this scope). Construct directly and keep the instance.
51
+ const localCtx = ctx.isolate('subprocess')
52
+ const localSubprocess = new LocalSubprocessRuntime(localCtx)
53
+
54
+ // Bind the per-record remote runtime builder into the shared seam state;
55
+ // instances are built lazily on first route and reused across refreshes.
56
+ core.seams.bindSub((record) => new SshSubprocessRuntime(ctx.isolate('subprocess'), core.engine, fixedRemoteState(record.alias, record.remoteRoot)))
57
+
58
+ const anchorRoot = core.ledger.anchorsRoot()
59
+ let warnedUnready = false
60
+
61
+ new SwitchSubprocessRuntime(ctx, {
62
+ local: localSubprocess,
63
+ worldFor: (cwd) => {
64
+ if (!core.seams.isReady() && !warnedUnready && cwd !== undefined && isPathUnderAnchor(anchorRoot, cwd)) {
65
+ warnedUnready = true
66
+ console.warn('[dsh-hardssh] subprocess routing is not ready yet (workspace ledger still loading) — running locally until the snapshot is applied')
67
+ }
68
+ return core.seams.runtimeForSub(cwd) ?? localSubprocess
69
+ },
70
+ })
71
+ }