feat(sshclient): add a promise-first SSH client with secure host verification and improve SSH key/config handling
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user