ppk-to-openssh
Version:
A pure JavaScript library for parsing and converting PuTTY private key files (.ppk) to OpenSSH format. Supports all PPK versions (v2 and v3) and key types (RSA, DSA, ECDSA, Ed25519). Handles both encrypted and unencrypted keys with full MAC verification.
246 lines (211 loc) • 6.87 kB
JavaScript
const fs = require('fs');
const path = require('path');
const { parseFromFile, PPKError } = require('../lib/index.js');
// Prompt for passphrase with hidden input
function promptPassphrase(message = 'Enter passphrase: ') {
return new Promise((resolve) => {
const stdin = process.stdin;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
process.stdout.write(message);
let input = '';
const onData = (char) => {
switch (char) {
case '\n':
case '\r':
case '\u0004': // Ctrl+D
stdin.setRawMode(false);
stdin.pause();
stdin.removeListener('data', onData);
process.stdout.write('\n');
resolve(input);
break;
case '\u0003': // Ctrl+C
process.stdout.write('\n');
process.exit(1);
break;
case '\u007f': // Backspace
if (input.length > 0) {
input = input.slice(0, -1);
}
break;
default:
if (char >= ' ') {
input += char;
}
break;
}
};
stdin.on('data', onData);
});
}
function showUsage() {
console.log(`
ppk-to-openssh - Convert PuTTY PPK files to OpenSSH format
Usage:
ppk-to-openssh <input.ppk> [output-prefix] [options]
Arguments:
input.ppk Path to the PPK file to convert
output-prefix Optional prefix for output files (default: uses input filename)
Options:
-p, --passphrase <pass> Passphrase for encrypted PPK files
-o, --output <dir> Output directory (default: current directory)
--public-only Only output the public key
--private-only Only output the private key
-v, --verbose Show detailed information
-h, --help Show this help message
Examples:
ppk-to-openssh mykey.ppk
ppk-to-openssh mykey.ppk id_rsa -p mypassphrase
ppk-to-openssh mykey.ppk --output ~/.ssh/
ppk-to-openssh encrypted.ppk --passphrase secret --verbose
ppk-to-openssh encrypted.ppk # Will prompt for passphrase if needed
Interactive Mode:
If a PPK file is encrypted and no passphrase is provided via -p,
the tool will automatically prompt for the passphrase (input hidden).
Output files:
<prefix> - Private key file
<prefix>.pub - Public key file
`);
}
function parseArgs(args) {
const options = {
inputFile: null,
outputPrefix: null,
passphrase: '',
outputDir: '.',
publicOnly: false,
privateOnly: false,
verbose: false,
help: false
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '-h' || arg === '--help') {
options.help = true;
} else if (arg === '-v' || arg === '--verbose') {
options.verbose = true;
} else if (arg === '--public-only') {
options.publicOnly = true;
} else if (arg === '--private-only') {
options.privateOnly = true;
} else if (arg === '-p' || arg === '--passphrase') {
options.passphrase = args[++i] || '';
} else if (arg === '-o' || arg === '--output') {
options.outputDir = args[++i] || '.';
} else if (!options.inputFile) {
options.inputFile = arg;
} else if (!options.outputPrefix) {
options.outputPrefix = arg;
}
}
return options;
}
async function main() {
const args = process.argv.slice(2);
if (args.length === 0) {
showUsage();
process.exit(1);
}
const options = parseArgs(args);
if (options.help) {
showUsage();
process.exit(0);
}
if (!options.inputFile) {
console.error('Error: Input PPK file is required');
showUsage();
process.exit(1);
}
// Determine output prefix
if (!options.outputPrefix) {
const basename = path.basename(options.inputFile, path.extname(options.inputFile));
options.outputPrefix = basename;
}
// Ensure output directory exists
try {
await fs.promises.mkdir(options.outputDir, { recursive: true });
} catch (error) {
console.error(`Error creating output directory: ${error.message}`);
process.exit(1);
}
try {
if (options.verbose) {
console.log(`Converting PPK file: ${options.inputFile}`);
console.log(`Output directory: ${options.outputDir}`);
console.log(`Output prefix: ${options.outputPrefix}`);
}
let result;
let passphrase = options.passphrase;
// Try parsing with provided passphrase (or empty string)
try {
result = await parseFromFile(options.inputFile, passphrase);
} catch (error) {
// If passphrase is required and we don't have one, prompt for it
if (error instanceof PPKError && error.code === 'PASSPHRASE_REQUIRED' && !passphrase) {
passphrase = await promptPassphrase('This PPK file is encrypted. Enter passphrase: ');
result = await parseFromFile(options.inputFile, passphrase);
} else {
throw error; // Re-throw other errors
}
}
if (options.verbose) {
console.log(`Key type: ${result.publicKey.split(' ')[0]}`);
console.log(`Fingerprint: ${result.fingerprint}`);
if (result.curve) {
console.log(`Curve: ${result.curve}`);
}
}
// Write private key
if (!options.publicOnly) {
const privateKeyPath = path.join(options.outputDir, options.outputPrefix);
await fs.promises.writeFile(privateKeyPath, result.privateKey);
// Set secure permissions on private key
try {
await fs.promises.chmod(privateKeyPath, 0o600);
} catch (err) {
if (options.verbose) {
console.warn(`Warning: Could not set permissions on private key: ${err.message}`);
}
}
if (options.verbose) {
console.log(`Private key written to: ${privateKeyPath}`);
}
}
// Write public key
if (!options.privateOnly) {
const publicKeyPath = path.join(options.outputDir, options.outputPrefix + '.pub');
await fs.promises.writeFile(publicKeyPath, result.publicKey + '\n');
if (options.verbose) {
console.log(`Public key written to: ${publicKeyPath}`);
}
}
if (!options.verbose) {
console.log('Conversion completed successfully');
}
} catch (error) {
if (error instanceof PPKError) {
console.error(`Error: ${error.message}`);
if (error.details.hint && options.verbose) {
console.error(`Hint: ${error.details.hint}`);
}
if (options.verbose) {
console.error(`Error code: ${error.code}`);
}
} else {
console.error(`Unexpected error: ${error.message}`);
if (options.verbose) {
console.error(error.stack);
}
}
process.exit(1);
}
}
if (require.main === module) {
main().catch(error => {
console.error('Fatal error:', error.message);
process.exit(1);
});
}