feat(sshclient): add a promise-first SSH client with secure host verification and improve SSH key/config handling

This commit is contained in:
2026-05-02 09:43:21 +00:00
parent 4a97d63c04
commit 3b20db79d0
17 changed files with 1332 additions and 170 deletions
+103 -3
View File
@@ -1,7 +1,107 @@
import * as plugins from './smartssh.plugins.js';
import { SshKey } from './smartssh.classes.sshkey.js';
export let sshKeyArrayFromDir = function (dirArg: string): SshKey[] {
let sshKeyArray: SshKey[] = []; // TODO
return sshKeyArray;
const unsafeHostMessage =
'SSH host aliases must be single, filename-safe values without whitespace or path separators.';
export const assertSafeHost = (hostArg: string) => {
if (!hostArg || hostArg === '.' || hostArg === '..') {
throw new Error(unsafeHostMessage);
}
if (!/^[A-Za-z0-9._@:%+-]+$/.test(hostArg)) {
throw new Error(unsafeHostMessage);
}
};
export const isSafeHost = (hostArg: string) => {
try {
assertSafeHost(hostArg);
return true;
} catch {
return false;
}
};
export const resolveSshDirPath = (dirPathArg?: string) => {
return plugins.path.resolve(
dirPathArg ?? plugins.path.join(plugins.smartpath.get.home(), '.ssh')
);
};
export const ensureSshDirSync = (dirPathArg: string) => {
plugins.fs.ensureDirSync(dirPathArg);
plugins.fs.chmodSync(dirPathArg, 0o700);
};
export const quoteSshConfigValue = (valueArg: string) => {
if (/^[A-Za-z0-9._~/:@%+-]+$/.test(valueArg)) {
return valueArg;
}
return `"${valueArg.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
};
export const fingerprintSha256 = (keyArg: Buffer | string) => {
const hash = plugins.crypto
.createHash('sha256')
.update(keyArg)
.digest('base64')
.replace(/=+$/, '');
return `SHA256:${hash}`;
};
export const fingerprintMd5 = (keyArg: Buffer | string) => {
const hash = plugins.crypto.createHash('md5').update(keyArg).digest('hex');
return hash.match(/.{2}/g)?.join(':') ?? hash;
};
export const sshKeyArrayFromDir = (dirArg: string): SshKey[] => {
const resolvedDir = resolveSshDirPath(dirArg);
if (!plugins.fs.pathExistsSync(resolvedDir)) {
return [];
}
const entries = plugins.fs.readdirSync(resolvedDir, { withFileTypes: true });
const keyMap = new Map<string, { private?: string; public?: string }>();
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const fileName = entry.name;
if (
fileName === 'config' ||
fileName === 'authorized_keys' ||
fileName === 'known_hosts' ||
fileName.startsWith('.')
) {
continue;
}
const isPublicKey = fileName.endsWith('.pub');
const host = isPublicKey ? fileName.slice(0, -4) : fileName;
if (!isSafeHost(host)) {
continue;
}
const keyRecord = keyMap.get(host) ?? {};
const filePath = plugins.path.join(resolvedDir, fileName);
const keyContent = plugins.fs.readFileSync(filePath, 'utf8');
if (isPublicKey) {
keyRecord.public = keyContent;
} else {
keyRecord.private = keyContent;
}
keyMap.set(host, keyRecord);
}
return Array.from(keyMap.entries()).map(([host, keyRecord]) => {
return new SshKey({
host,
private: keyRecord.private,
public: keyRecord.public,
});
});
};