dsh-ssh-manage 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +7 -7
- package/README.md +10 -9
- package/client.js +319 -19
- package/index.js +120 -26
- package/lib/known-hosts.js +136 -0
- package/lib/metadata.js +28 -11
- package/lib/ssh-client.js +20 -3
- package/lib/ssh-config.js +15 -2
- package/lib/storage.js +58 -11
- package/lib/vault.js +30 -12
- package/package.json +1 -1
package/lib/storage.js
CHANGED
|
@@ -3,6 +3,8 @@ import * as fs from 'node:fs/promises'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
5
|
const KEY_BYTES = 32
|
|
6
|
+
const storeLocks = new Map()
|
|
7
|
+
const keyCreations = new Map()
|
|
6
8
|
|
|
7
9
|
export function storagePaths(paths = {}) {
|
|
8
10
|
const stateDir = paths.stateDir || path.join(paths.home || process.env.HOME || process.cwd(), '.dsh', '.ssh-manager')
|
|
@@ -15,6 +17,23 @@ export function storagePaths(paths = {}) {
|
|
|
15
17
|
}
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Serialize one async read-modify-write sequence per store key. Callers must
|
|
22
|
+
* only take the lock around their own read/write pair; the unlocked helpers in
|
|
23
|
+
* the vault, metadata and SSH config modules exist so nested locks never wait
|
|
24
|
+
* on themselves.
|
|
25
|
+
*/
|
|
26
|
+
export function withStoreLock(key, fn) {
|
|
27
|
+
const previous = storeLocks.get(key) ?? Promise.resolve()
|
|
28
|
+
const next = previous.then(() => fn())
|
|
29
|
+
const tail = next.then(() => undefined, () => undefined)
|
|
30
|
+
storeLocks.set(key, tail)
|
|
31
|
+
void tail.then(() => {
|
|
32
|
+
if (storeLocks.get(key) === tail) storeLocks.delete(key)
|
|
33
|
+
})
|
|
34
|
+
return next
|
|
35
|
+
}
|
|
36
|
+
|
|
18
37
|
export async function atomicWriteFile(filePath, data, mode = 0o600) {
|
|
19
38
|
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 })
|
|
20
39
|
const temporary = `${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`
|
|
@@ -28,23 +47,51 @@ export async function atomicWriteFile(filePath, data, mode = 0o600) {
|
|
|
28
47
|
}
|
|
29
48
|
}
|
|
30
49
|
|
|
50
|
+
function decodeKey(raw) {
|
|
51
|
+
if (Buffer.isBuffer(raw) && raw.length === KEY_BYTES) return raw
|
|
52
|
+
const decoded = Buffer.from(String(raw).trim(), 'base64')
|
|
53
|
+
if (decoded.length !== KEY_BYTES) throw new Error('SSH manager key has an invalid length')
|
|
54
|
+
return decoded
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readKey(keyPath) {
|
|
58
|
+
const raw = await fs.readFile(keyPath)
|
|
59
|
+
await fs.chmod(keyPath, 0o600).catch(() => {})
|
|
60
|
+
return decodeKey(raw)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Create the key exactly once per path in this process; adopt another writer's key on EEXIST. */
|
|
64
|
+
function createKeyOnce(keyPath) {
|
|
65
|
+
const pending = keyCreations.get(keyPath)
|
|
66
|
+
if (pending) return pending
|
|
67
|
+
const creation = (async () => {
|
|
68
|
+
const key = randomBytes(KEY_BYTES)
|
|
69
|
+
await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 })
|
|
70
|
+
try {
|
|
71
|
+
await fs.writeFile(keyPath, key.toString('base64'), { encoding: 'utf8', mode: 0o600, flag: 'wx' })
|
|
72
|
+
return key
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error?.code !== 'EEXIST') throw error
|
|
75
|
+
// Another process created the key first: never overwrite an existing key.
|
|
76
|
+
return await readKey(keyPath)
|
|
77
|
+
}
|
|
78
|
+
})()
|
|
79
|
+
keyCreations.set(keyPath, creation)
|
|
80
|
+
const forget = () => {
|
|
81
|
+
if (keyCreations.get(keyPath) === creation) keyCreations.delete(keyPath)
|
|
82
|
+
}
|
|
83
|
+
void creation.then(forget, forget)
|
|
84
|
+
return creation
|
|
85
|
+
}
|
|
86
|
+
|
|
31
87
|
export async function loadOrCreateKey(paths) {
|
|
32
88
|
const resolved = storagePaths(paths)
|
|
33
89
|
try {
|
|
34
|
-
|
|
35
|
-
await fs.chmod(resolved.keyPath, 0o600).catch(() => {})
|
|
36
|
-
if (raw.length === KEY_BYTES) return raw
|
|
37
|
-
const decoded = Buffer.from(raw.toString('utf8').trim(), 'base64')
|
|
38
|
-
if (decoded.length !== KEY_BYTES) throw new Error('SSH manager key has an invalid length')
|
|
39
|
-
return decoded
|
|
90
|
+
return await readKey(resolved.keyPath)
|
|
40
91
|
} catch (error) {
|
|
41
92
|
if (error?.code !== 'ENOENT') throw error
|
|
42
|
-
const key = randomBytes(KEY_BYTES)
|
|
43
|
-
await atomicWriteFile(resolved.keyPath, key.toString('base64'), 0o600)
|
|
44
|
-
// The base64 representation is easier to inspect safely while retaining 256 bits.
|
|
45
|
-
const encoded = await fs.readFile(resolved.keyPath, 'utf8')
|
|
46
|
-
return Buffer.from(encoded.trim(), 'base64')
|
|
47
93
|
}
|
|
94
|
+
return createKeyOnce(resolved.keyPath)
|
|
48
95
|
}
|
|
49
96
|
|
|
50
97
|
export async function readJson(filePath, fallback) {
|
package/lib/vault.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
2
|
-
import { atomicWriteFile, loadOrCreateKey, readJson, storagePaths } from './storage.js'
|
|
2
|
+
import { atomicWriteFile, loadOrCreateKey, readJson, storagePaths, withStoreLock } from './storage.js'
|
|
3
3
|
|
|
4
4
|
const ALGORITHM = 'aes-256-gcm'
|
|
5
5
|
const IV_BYTES = 12
|
|
@@ -41,7 +41,8 @@ export function decryptRecords(payload, key) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/** Read the vault without taking the store lock; callers that mutate must hold it. */
|
|
45
|
+
async function loadVaultUnlocked(paths) {
|
|
45
46
|
const resolved = storagePaths(paths)
|
|
46
47
|
const key = await loadOrCreateKey(resolved)
|
|
47
48
|
const payload = await readJson(resolved.vaultPath, null)
|
|
@@ -49,7 +50,8 @@ export async function loadVault(paths) {
|
|
|
49
50
|
return { paths: resolved, key, records }
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
|
|
53
|
+
/** Write the vault without taking the store lock; callers that read-modify-write must hold it. */
|
|
54
|
+
async function saveVaultUnlocked(paths, records) {
|
|
53
55
|
const resolved = storagePaths(paths)
|
|
54
56
|
const key = await loadOrCreateKey(resolved)
|
|
55
57
|
const payload = encryptRecords(normalizeRecords(records), key)
|
|
@@ -57,22 +59,38 @@ export async function saveVault(paths, records) {
|
|
|
57
59
|
return { saved: true }
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
function vaultLockKey(paths) {
|
|
63
|
+
return `vault:${storagePaths(paths).stateDir}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadVault(paths) {
|
|
67
|
+
return loadVaultUnlocked(paths)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function saveVault(paths, records) {
|
|
71
|
+
return withStoreLock(vaultLockKey(paths), () => saveVaultUnlocked(paths, records))
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
export async function setPassword(paths, alias, password) {
|
|
61
75
|
const key = assertAlias(alias)
|
|
62
76
|
if (typeof password !== 'string' || password.length === 0) throw new Error('Password is required')
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
77
|
+
return withStoreLock(vaultLockKey(paths), async () => {
|
|
78
|
+
const vault = await loadVaultUnlocked(paths)
|
|
79
|
+
vault.records[key] = password
|
|
80
|
+
await saveVaultUnlocked(vault.paths, vault.records)
|
|
81
|
+
return { saved: true, host: key }
|
|
82
|
+
})
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
export async function removePassword(paths, alias) {
|
|
70
86
|
const key = assertAlias(alias)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
87
|
+
return withStoreLock(vaultLockKey(paths), async () => {
|
|
88
|
+
const vault = await loadVaultUnlocked(paths)
|
|
89
|
+
const removed = Object.prototype.hasOwnProperty.call(vault.records, key)
|
|
90
|
+
delete vault.records[key]
|
|
91
|
+
if (removed) await saveVaultUnlocked(vault.paths, vault.records)
|
|
92
|
+
return { removed }
|
|
93
|
+
})
|
|
76
94
|
}
|
|
77
95
|
|
|
78
96
|
export async function hasPassword(paths, alias) {
|