@tiphareth/dsh-hardssh 0.1.2-alpha
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/README.md +50 -0
- package/cordis.patch.yml +26 -0
- package/lib/client.js +17794 -0
- package/lib/environment-BL1jddfB.js +449 -0
- package/lib/fs.js +478 -0
- package/lib/index.js +6758 -0
- package/lib/subprocess.js +600 -0
- package/lib/switch-fs-CAJpFY9C.js +193 -0
- package/lib/switch-fs-RrZtG2gv.js +210 -0
- package/lib/types/backend.d.ts +108 -0
- package/lib/types/base/capability.d.ts +107 -0
- package/lib/types/base/index.d.ts +18 -0
- package/lib/types/base/ledger-router.d.ts +48 -0
- package/lib/types/base/ledger.d.ts +82 -0
- package/lib/types/base/model.d.ts +108 -0
- package/lib/types/base/namespace.d.ts +57 -0
- package/lib/types/base/plugin.d.ts +102 -0
- package/lib/types/base/registry.d.ts +60 -0
- package/lib/types/base/router.d.ts +40 -0
- package/lib/types/client/api.d.ts +85 -0
- package/lib/types/client/directory-flow.d.ts +82 -0
- package/lib/types/client/icons.d.ts +19 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/locales.d.ts +104 -0
- package/lib/types/client/manager-button.d.ts +32 -0
- package/lib/types/client/migrate.d.ts +20 -0
- package/lib/types/client/ssh/api.d.ts +89 -0
- package/lib/types/client/ssh/apply.d.ts +23 -0
- package/lib/types/client/ssh/locales.d.ts +156 -0
- package/lib/types/client/ssh/mount.d.ts +13 -0
- package/lib/types/client/ssh/panel/ClusterTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/HostFingerprintDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/HostFormDialog.d.ts +13 -0
- package/lib/types/client/ssh/panel/HostsTab.d.ts +10 -0
- package/lib/types/client/ssh/panel/SessionSecretDialog.d.ts +16 -0
- package/lib/types/client/ssh/panel/SshPanel.d.ts +14 -0
- package/lib/types/client/ssh/panel/TerminalTab.d.ts +12 -0
- package/lib/types/client/ssh/panel/TransferTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/TunnelsTab.d.ts +8 -0
- package/lib/types/client/ssh/panel/controller.d.ts +23 -0
- package/lib/types/client/ssh/panel/helpers.d.ts +15 -0
- package/lib/types/client/ssh/panel/xterm.css.d.ts +3 -0
- package/lib/types/client/ssh/sidebar-entry.d.ts +25 -0
- package/lib/types/client/state.d.ts +32 -0
- package/lib/types/client/text.d.ts +11 -0
- package/lib/types/client/workspace-badges.d.ts +38 -0
- package/lib/types/client/workspace-gate.d.ts +15 -0
- package/lib/types/client-http.d.ts +15 -0
- package/lib/types/core.d.ts +43 -0
- package/lib/types/fs.d.ts +36 -0
- package/lib/types/host-http.d.ts +25 -0
- package/lib/types/index.d.ts +53 -0
- package/lib/types/ledger.d.ts +132 -0
- package/lib/types/protocol.d.ts +101 -0
- package/lib/types/providers/index.d.ts +19 -0
- package/lib/types/providers/local/provider.d.ts +58 -0
- package/lib/types/providers/ssh/provider.d.ts +112 -0
- package/lib/types/remote/environment.d.ts +29 -0
- package/lib/types/remote/output.d.ts +38 -0
- package/lib/types/remote/remote-fs.d.ts +69 -0
- package/lib/types/remote/remote-process.d.ts +41 -0
- package/lib/types/remote/remote-subprocess.d.ts +31 -0
- package/lib/types/remote/remote-terminal.d.ts +41 -0
- package/lib/types/remote-runner.d.ts +83 -0
- package/lib/types/remote-search.d.ts +50 -0
- package/lib/types/routes.d.ts +24 -0
- package/lib/types/runtime/workspace-core.d.ts +59 -0
- package/lib/types/seam-state.d.ts +69 -0
- package/lib/types/shell.d.ts +8 -0
- package/lib/types/ssh/connection/lease.d.ts +16 -0
- package/lib/types/ssh/connection/pool.d.ts +57 -0
- package/lib/types/ssh/engine.d.ts +434 -0
- package/lib/types/ssh/exec/output.d.ts +31 -0
- package/lib/types/ssh/known-hosts.d.ts +89 -0
- package/lib/types/ssh/plugin.d.ts +57 -0
- package/lib/types/ssh/protocol.d.ts +236 -0
- package/lib/types/ssh/routes.d.ts +44 -0
- package/lib/types/ssh/store.d.ts +107 -0
- package/lib/types/ssh/tools.d.ts +35 -0
- package/lib/types/ssh/transfer/progress.d.ts +14 -0
- package/lib/types/ssh/vault.d.ts +110 -0
- package/lib/types/subprocess.d.ts +32 -0
- package/lib/types/switch/switch-fs.d.ts +88 -0
- package/lib/types/switch/switch-subprocess.d.ts +34 -0
- package/lib/types/tools.d.ts +12 -0
- package/package.json +142 -0
- package/src/backend.ts +624 -0
- package/src/base/capability.ts +86 -0
- package/src/base/index.ts +18 -0
- package/src/base/ledger-router.ts +128 -0
- package/src/base/ledger.ts +299 -0
- package/src/base/model.ts +118 -0
- package/src/base/namespace.ts +102 -0
- package/src/base/plugin.ts +170 -0
- package/src/base/registry.ts +109 -0
- package/src/base/router.ts +43 -0
- package/src/client/api.ts +196 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/directory-flow.tsx +482 -0
- package/src/client/icons.tsx +50 -0
- package/src/client/index.ts +210 -0
- package/src/client/locales.ts +105 -0
- package/src/client/manager-button.tsx +269 -0
- package/src/client/migrate.ts +109 -0
- package/src/client/ssh/api.ts +411 -0
- package/src/client/ssh/apply.ts +50 -0
- package/src/client/ssh/locales.ts +322 -0
- package/src/client/ssh/mount.tsx +83 -0
- package/src/client/ssh/panel/ClusterTab.tsx +123 -0
- package/src/client/ssh/panel/HostFingerprintDialog.tsx +82 -0
- package/src/client/ssh/panel/HostFormDialog.tsx +228 -0
- package/src/client/ssh/panel/HostsTab.tsx +236 -0
- package/src/client/ssh/panel/SessionSecretDialog.tsx +80 -0
- package/src/client/ssh/panel/SshPanel.tsx +77 -0
- package/src/client/ssh/panel/TerminalTab.tsx +176 -0
- package/src/client/ssh/panel/TransferTab.tsx +232 -0
- package/src/client/ssh/panel/TunnelsTab.tsx +177 -0
- package/src/client/ssh/panel/controller.ts +48 -0
- package/src/client/ssh/panel/helpers.ts +26 -0
- package/src/client/ssh/panel/panel.module.css +1006 -0
- package/src/client/ssh/panel/xterm.css.ts +2 -0
- package/src/client/ssh/sidebar-entry.ts +123 -0
- package/src/client/state.ts +99 -0
- package/src/client/text.ts +22 -0
- package/src/client/workspace-badges.ts +99 -0
- package/src/client/workspace-gate.ts +232 -0
- package/src/client/workspace.module.css +283 -0
- package/src/client-http.ts +45 -0
- package/src/core.ts +47 -0
- package/src/fs.ts +85 -0
- package/src/host-http.ts +74 -0
- package/src/index.ts +244 -0
- package/src/ledger.ts +416 -0
- package/src/protocol.ts +114 -0
- package/src/providers/index.ts +34 -0
- package/src/providers/local/provider.ts +179 -0
- package/src/providers/ssh/provider.ts +274 -0
- package/src/remote/environment.ts +123 -0
- package/src/remote/output.ts +142 -0
- package/src/remote/remote-fs.ts +532 -0
- package/src/remote/remote-process.ts +203 -0
- package/src/remote/remote-subprocess.ts +159 -0
- package/src/remote/remote-terminal.ts +141 -0
- package/src/remote-runner.ts +201 -0
- package/src/remote-search.ts +163 -0
- package/src/routes.ts +395 -0
- package/src/runtime/workspace-core.ts +154 -0
- package/src/seam-state.ts +185 -0
- package/src/shell.ts +10 -0
- package/src/ssh/connection/lease.ts +17 -0
- package/src/ssh/connection/pool.ts +275 -0
- package/src/ssh/engine.ts +1761 -0
- package/src/ssh/exec/output.ts +70 -0
- package/src/ssh/known-hosts.ts +214 -0
- package/src/ssh/plugin.ts +184 -0
- package/src/ssh/protocol.ts +227 -0
- package/src/ssh/routes.ts +892 -0
- package/src/ssh/store.ts +544 -0
- package/src/ssh/tools.ts +402 -0
- package/src/ssh/transfer/progress.ts +75 -0
- package/src/ssh/vault.ts +477 -0
- package/src/subprocess.ts +71 -0
- package/src/switch/switch-fs.ts +253 -0
- package/src/switch/switch-subprocess.ts +59 -0
- package/src/tools.ts +221 -0
package/src/ssh/vault.ts
ADDED
|
@@ -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
|
+
}
|