UNPKG

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.

1,286 lines (1,119 loc) 41.1 kB
const crypto = require('crypto'); const { argon2id, argon2i, argon2d } = require('hash-wasm'); /** * Universal Argon2 implementation using hash-wasm * Works in both browsers and Node.js environments */ class PureArgon2 { constructor() { // Simple constructor for hash-wasm integration } async hash({ pass, salt, time, mem, hashLen, parallelism = 1, type = 2 }) { const password = typeof pass === 'string' ? pass : Buffer.from(pass).toString('utf8'); const saltBuffer = Buffer.isBuffer(salt) ? salt : Buffer.from(salt); // Parameter validation if (time < 1) throw new Error('time must be at least 1'); if (mem < 8 * parallelism) throw new Error('memory must be at least 8*parallelism'); if (parallelism < 1) throw new Error('parallelism must be at least 1'); if (hashLen < 4) throw new Error('hash length must be at least 4'); // Select the appropriate Argon2 variant based on type let argon2Function; switch (type) { case 0: // Argon2d argon2Function = argon2d; break; case 1: // Argon2i argon2Function = argon2i; break; case 2: // Argon2id default: argon2Function = argon2id; break; } // Use hash-wasm's Argon2 implementation const result = await argon2Function({ password: password, salt: saltBuffer, parallelism: parallelism, iterations: time, memorySize: mem, // in KB hashLength: hashLen, outputType: 'binary' }); return { hash: Buffer.from(result) }; } } // Argon2 type constants const ArgonType = { Argon2d: 0, Argon2i: 1, Argon2id: 2 }; // Create singleton instance const pureArgon2 = new PureArgon2(); // Custom error class for better error handling class PPKError extends Error { constructor(message, code, details = {}) { super(message); this.name = 'PPKError'; this.code = code; this.details = details; } } class PPKParser { constructor(options = {}) { this.options = options; this.supportedAlgorithms = ['ssh-rsa', 'ssh-dss', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519']; this.maxFileSize = options.maxFileSize || 1024 * 1024; // 1MB limit } /** * Parse a PPK file and convert to OpenSSH format * @param {string} ppkContent - The PPK file content as string * @param {string} passphrase - Optional passphrase for encrypted keys * @returns {Object} Object containing publicKey and privateKey in OpenSSH format, * plus ssh2StreamsCompatible method for pure-js-sftp */ async parse(ppkContent, passphrase = '') { let ppkData; try { // Input validation if (!ppkContent || typeof ppkContent !== 'string') { throw new PPKError( 'Invalid input: PPK content must be a non-empty string', 'INVALID_INPUT' ); } if (ppkContent.length > this.maxFileSize) { throw new PPKError( `PPK file too large (max ${this.maxFileSize / 1024}KB)`, 'FILE_TOO_LARGE', { size: ppkContent.length } ); } // Format detection if (ppkContent.includes('-----BEGIN OPENSSH PRIVATE KEY-----')) { throw new PPKError( 'This appears to be an OpenSSH key, not a PPK file', 'WRONG_FORMAT', { hint: 'Use this key directly with ssh, no conversion needed' } ); } if (ppkContent.includes('-----BEGIN RSA PRIVATE KEY-----') || ppkContent.includes('-----BEGIN DSA PRIVATE KEY-----') || ppkContent.includes('-----BEGIN EC PRIVATE KEY-----')) { throw new PPKError( 'This appears to be a PEM format key, not a PPK file', 'WRONG_FORMAT', { hint: 'Use ssh-keygen to convert: ssh-keygen -p -m PEM -f keyfile' } ); } if (!ppkContent.includes('PuTTY-User-Key-File-')) { throw new PPKError( 'Invalid PPK file: missing PuTTY header', 'INVALID_PPK_FORMAT', { hint: 'Ensure this is a valid PuTTY private key file (.ppk)' } ); } const lines = ppkContent.split(/\r?\n/); ppkData = this.parsePPKStructure(lines); // Validate PPK version if (ppkData.version !== 2 && ppkData.version !== 3) { throw new PPKError( `Unsupported PPK version: ${ppkData.version}`, 'UNSUPPORTED_VERSION', { version: ppkData.version, hint: 'Only PPK versions 2 and 3 are supported' } ); } // Validate required fields if (!ppkData.algorithm) { throw new PPKError('Invalid PPK: missing algorithm', 'MISSING_FIELD'); } if (ppkData.publicLines.length === 0) { throw new PPKError('Invalid PPK: missing public key data', 'MISSING_FIELD'); } if (ppkData.privateLines.length === 0) { throw new PPKError('Invalid PPK: missing private key data', 'MISSING_FIELD'); } // Decode public key with error handling let publicKeyData, privateKeyData; try { if (!ppkData.publicLines || ppkData.publicLines.length === 0) { throw new Error('No public key data found'); } if (!ppkData.privateLines || ppkData.privateLines.length === 0) { throw new Error('No private key data found'); } const publicBase64 = ppkData.publicLines.join(''); const privateBase64 = ppkData.privateLines.join(''); if (!publicBase64 || !privateBase64) { throw new Error('Empty key data'); } publicKeyData = Buffer.from(publicBase64, 'base64'); privateKeyData = Buffer.from(privateBase64, 'base64'); } catch (e) { throw new PPKError( 'Invalid PPK data: corrupted base64 encoding', 'INVALID_BASE64', { originalError: e.message } ); } // Handle encryption if (ppkData.encryption !== 'none') { if (!passphrase) { throw new PPKError( 'Passphrase required for encrypted key', 'PASSPHRASE_REQUIRED', { hint: 'Please provide the passphrase used to encrypt this key' } ); } privateKeyData = await this.decryptPrivateKey(privateKeyData, passphrase, ppkData); } // Verify MAC - for unencrypted keys, always use empty passphrase for MAC verification const macPassphrase = ppkData.encryption === 'none' ? '' : passphrase; const isValid = await this.verifyMAC(publicKeyData, privateKeyData, ppkData, macPassphrase); if (!isValid) { throw new PPKError( 'MAC verification failed', 'INVALID_MAC', { hint: ppkData.encryption !== 'none' ? 'Wrong passphrase or corrupted key file' : 'Key file may be corrupted or tampered with' } ); } // Convert to OpenSSH format based on algorithm const result = await this.convertToOpenSSH(ppkData.algorithm, publicKeyData, privateKeyData, ppkData.comment); // Add algorithm and comment to the result result.algorithm = ppkData.algorithm; result.comment = ppkData.comment; // Add ssh2-streams compatibility method only for RSA keys that need it if (ppkData.algorithm === 'ssh-rsa' || result.privateKey.includes('BEGIN RSA PRIVATE KEY')) { try { result.getCompatiblePrivateKey = async (signatureAlgorithm = 'sha512') => { return await this.createSSH2StreamsCompatibleKey(result.privateKey, signatureAlgorithm); }; } catch (error) { // If ssh2-streams is not available, just skip the compatibility method // This is okay since it's an optional enhancement } } return result; } catch (error) { // Re-throw PPKErrors as-is if (error instanceof PPKError) { throw error; } // Wrap unexpected errors with more context throw new PPKError( `Failed to parse PPK file: ${error.message}`, 'PARSE_ERROR', { originalError: error, stack: error.stack, ppkDataStructure: ppkData ? { version: ppkData.version, algorithm: ppkData.algorithm, encryption: ppkData.encryption, publicLinesCount: ppkData.publicLines ? ppkData.publicLines.length : 'undefined', privateLinesCount: ppkData.privateLines ? ppkData.privateLines.length : 'undefined' } : 'undefined' } ); } } /** * Parse PPK file structure */ parsePPKStructure(lines) { const data = { version: 2, algorithm: '', encryption: 'none', comment: '', publicLines: [], privateLines: [], privateMac: '', argon2: {} }; let currentSection = null; let lineCount = 0; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith('PuTTY-User-Key-File-')) { const parts = trimmed.split(':'); if (parts && parts.length > 0) { // Extract version from "PuTTY-User-Key-File-2: ssh-rsa" const headerPart = parts[0]; // "PuTTY-User-Key-File-2" const versionMatch = headerPart.match(/PuTTY-User-Key-File-(\d+)/); data.version = versionMatch ? parseInt(versionMatch[1]) : 2; if (parts.length > 1) { data.algorithm = parts[1].trim(); } } } else if (trimmed.startsWith('Encryption:')) { const parts = trimmed.split(':'); data.encryption = parts.length > 1 ? parts[1].trim() : 'none'; } else if (trimmed.startsWith('Comment:')) { data.comment = trimmed.split(':').slice(1).join(':').trim(); } else if (trimmed.startsWith('Public-Lines:')) { const parts = trimmed.split(':'); lineCount = parts.length > 1 ? parseInt(parts[1].trim()) : 0; currentSection = 'public'; } else if (trimmed.startsWith('Private-Lines:')) { const parts = trimmed.split(':'); lineCount = parts.length > 1 ? parseInt(parts[1].trim()) : 0; currentSection = 'private'; } else if (trimmed.startsWith('Private-MAC:')) { const parts = trimmed.split(':'); data.privateMac = parts.length > 1 ? parts[1].trim() : ''; } else if (trimmed.startsWith('Key-Derivation:')) { const parts = trimmed.split(':'); data.argon2.flavor = parts.length > 1 ? parts[1].trim() : ''; } else if (trimmed.startsWith('Argon2-Memory:')) { const parts = trimmed.split(':'); data.argon2.memory = parts.length > 1 ? parseInt(parts[1].trim()) : 0; } else if (trimmed.startsWith('Argon2-Passes:')) { const parts = trimmed.split(':'); data.argon2.passes = parts.length > 1 ? parseInt(parts[1].trim()) : 0; } else if (trimmed.startsWith('Argon2-Parallelism:')) { const parts = trimmed.split(':'); data.argon2.parallelism = parts.length > 1 ? parseInt(parts[1].trim()) : 0; } else if (trimmed.startsWith('Argon2-Salt:')) { const parts = trimmed.split(':'); data.argon2.salt = parts.length > 1 ? parts[1].trim() : ''; } else if (currentSection && lineCount > 0) { if (currentSection === 'public') { data.publicLines.push(trimmed); } else if (currentSection === 'private') { data.privateLines.push(trimmed); } lineCount--; if (lineCount === 0) { currentSection = null; } } } return data; } /** * Decrypt private key data */ async decryptPrivateKey(encryptedData, passphrase, ppkData) { if (ppkData.encryption !== 'aes256-cbc') { throw new PPKError( `Unsupported encryption type: ${ppkData.encryption}`, 'UNSUPPORTED_ENCRYPTION', { encryption: ppkData.encryption, hint: 'Only aes256-cbc encryption is supported' } ); } let key, iv, macKey; if (ppkData.version === 3 && ppkData.argon2.flavor) { // PPK v3 uses Argon2 for key derivation const salt = Buffer.from(ppkData.argon2.salt, 'hex'); // Map PPK argon2 flavor to pure argon2 types const argon2Type = { 'Argon2i': ArgonType.Argon2i, 'Argon2d': ArgonType.Argon2d, 'Argon2id': ArgonType.Argon2id }[ppkData.argon2.flavor]; if (argon2Type === undefined) { throw new PPKError( `Unsupported Argon2 variant: ${ppkData.argon2.flavor}`, 'UNSUPPORTED_ARGON2', { flavor: ppkData.argon2.flavor } ); } const result = await pureArgon2.hash({ pass: passphrase, salt: salt, time: ppkData.argon2.passes, mem: ppkData.argon2.memory, hashLen: 80, // 32 + 16 + 32 parallelism: ppkData.argon2.parallelism, type: argon2Type }); const derivedKey = Buffer.from(result.hash); key = derivedKey.slice(0, 32); iv = derivedKey.slice(32, 48); macKey = derivedKey.slice(48, 80); // Store macKey for later MAC verification ppkData._derivedMacKey = macKey; } else { // PPK v2 key derivation const keyData = this.deriveKeyV2(passphrase); key = keyData.key; iv = Buffer.alloc(16, 0); // PPK v2 uses zero IV } // Decrypt using AES-256-CBC const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv); decipher.setAutoPadding(false); const decrypted = Buffer.concat([ decipher.update(encryptedData), decipher.final() ]); return decrypted; } /** * PPK v2 key derivation */ deriveKeyV2(passphrase) { // First SHA-1 hash with sequence number 0 const hash1 = crypto.createHash('sha1'); hash1.update(Buffer.from([0, 0, 0, 0])); hash1.update(passphrase, 'utf8'); const digest1 = hash1.digest(); // Second SHA-1 hash with sequence number 1 const hash2 = crypto.createHash('sha1'); hash2.update(Buffer.from([0, 0, 0, 1])); hash2.update(passphrase, 'utf8'); const digest2 = hash2.digest(); // Concatenate and take first 32 bytes for AES-256 key const key = Buffer.concat([digest1, digest2]).slice(0, 32); return { key }; } /** * Verify MAC */ async verifyMAC(publicKeyData, privateKeyData, ppkData, passphrase) { const macHex = ppkData.privateMac.toLowerCase(); let computedMac; if (ppkData.version === 3) { // PPK v3 uses HMAC-SHA-256 but same input format as v2 const macKey = ppkData._derivedMacKey || this.deriveMACKeyV3(passphrase); const hmac = crypto.createHmac('sha256', macKey); // PPK v3 MAC input includes algorithm, encryption, comment, public key, private key // This is the same format as PPK v2, just with SHA-256 instead of SHA-1 hmac.update(this.encodeString(ppkData.algorithm)); hmac.update(this.encodeString(ppkData.encryption)); hmac.update(this.encodeString(ppkData.comment)); hmac.update(this.encodeString(publicKeyData)); hmac.update(this.encodeString(privateKeyData)); computedMac = hmac.digest('hex'); } else { // PPK v2 uses HMAC-SHA-1 const macKey = this.deriveMACKeyV2(passphrase); const hmac = crypto.createHmac('sha1', macKey); // MAC input includes algorithm name hmac.update(this.encodeString(ppkData.algorithm)); hmac.update(this.encodeString(ppkData.encryption)); hmac.update(this.encodeString(ppkData.comment)); hmac.update(this.encodeString(publicKeyData)); hmac.update(this.encodeString(privateKeyData)); computedMac = hmac.digest('hex'); } return computedMac === macHex; } /** * Derive MAC key for PPK v2 */ deriveMACKeyV2(passphrase) { const hash = crypto.createHash('sha1'); hash.update('putty-private-key-file-mac-key'); hash.update(passphrase || '', 'utf8'); return hash.digest(); } /** * Derive MAC key for PPK v3 */ deriveMACKeyV3(passphrase) { // For unencrypted keys in PPK v3, use an empty MAC key (32 zero bytes) // This is different from PPK v2 and encrypted PPK v3 keys if (!passphrase || passphrase === '') { return Buffer.alloc(32); // Empty MAC key for unencrypted PPK v3 } // For encrypted keys, derive MAC key from passphrase const hash = crypto.createHash('sha256'); hash.update('putty-private-key-file-mac-key'); hash.update(passphrase, 'utf8'); return hash.digest(); } /** * Encode string/buffer with length prefix for MAC calculation */ encodeString(data) { const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8'); const length = Buffer.allocUnsafe(4); length.writeUInt32BE(buffer.length, 0); return Buffer.concat([length, buffer]); } /** * Convert to OpenSSH format */ async convertToOpenSSH(algorithm, publicKeyData, privateKeyData, comment) { // Default to PEM format for backward compatibility // Users can explicitly request OpenSSH format via options const outputFormat = this.options.outputFormat || 'pem'; switch (algorithm) { case 'ssh-rsa': return await this.convertRSAToOpenSSH(publicKeyData, privateKeyData, comment, outputFormat); case 'ssh-dss': return await this.convertDSAToOpenSSH(publicKeyData, privateKeyData, comment, outputFormat); case 'ssh-ed25519': // Ed25519 always uses OpenSSH format (it's the standard) return this.convertEd25519ToOpenSSH(publicKeyData, privateKeyData, comment); case 'ecdsa-sha2-nistp256': case 'ecdsa-sha2-nistp384': case 'ecdsa-sha2-nistp521': return await this.convertECDSAToOpenSSH(algorithm, publicKeyData, privateKeyData, comment, outputFormat); default: throw new PPKError( `Unsupported algorithm: ${algorithm}`, 'UNSUPPORTED_ALGORITHM', { algorithm: algorithm, supported: this.supportedAlgorithms } ); } } /** * Convert RSA key to OpenSSH format using Node.js crypto */ async convertRSAToOpenSSH(publicKeyData, privateKeyData, comment, outputFormat = 'pem') { // Parse public key components const publicKey = this.parseSSHPublicKey(publicKeyData); // Parse private key components const reader = new BinaryReader(privateKeyData); const d = reader.readBuffer(); // private exponent const p = reader.readBuffer(); // prime1 const q = reader.readBuffer(); // prime2 const iqmp = reader.readBuffer(); // coefficient // Calculate missing components const n = publicKey.n; const e = publicKey.e; // Calculate dP and dQ const pBigInt = this.bufferToBigInt(p); const qBigInt = this.bufferToBigInt(q); const dBigInt = this.bufferToBigInt(d); const dP = dBigInt % (pBigInt - 1n); const dQ = dBigInt % (qBigInt - 1n); // Create OpenSSH public key const publicKeySSH = `ssh-rsa ${publicKeyData.toString('base64')} ${comment}`; let privateKeyPem; if (outputFormat === 'openssh') { // Create OpenSSH private key format privateKeyPem = this.createOpenSSHPrivateKey({ keyType: 'ssh-rsa', publicKeyData, privateKeyComponents: { n, e, d, p, q, iqmp }, comment, passphrase: this.options.outputPassphrase }); } else { // Legacy PEM format const privateKeyDer = this.createRSAPrivateKeyDER(n, e, d, p, q, dP, dQ, iqmp); const base64Data = privateKeyDer.toString('base64'); const base64Lines = base64Data.match(/.{1,64}/g) || [base64Data]; privateKeyPem = '-----BEGIN RSA PRIVATE KEY-----\n' + base64Lines.join('\n') + '\n-----END RSA PRIVATE KEY-----\n'; } return { privateKey: privateKeyPem, publicKey: publicKeySSH, fingerprint: this.generateFingerprint(publicKeyData) }; } /** * Convert DSA key to OpenSSH format */ async convertDSAToOpenSSH(publicKeyData, privateKeyData, comment, outputFormat = 'pem') { // Parse public key components const pubReader = new BinaryReader(publicKeyData); const keyType = pubReader.readString(); if (keyType !== 'ssh-dss') { throw new Error(`Expected ssh-dss, got ${keyType}`); } const p = pubReader.readBuffer(); // prime const q = pubReader.readBuffer(); // subprime const g = pubReader.readBuffer(); // base const y = pubReader.readBuffer(); // public key // Parse private key components const privReader = new BinaryReader(privateKeyData); const x = privReader.readBuffer(); // private key // Create OpenSSH public key const publicKeySSH = `ssh-dss ${publicKeyData.toString('base64')} ${comment}`; let privateKeyPem; if (outputFormat === 'openssh') { // Create OpenSSH format for DSA privateKeyPem = this.createOpenSSHPrivateKey({ keyType: 'ssh-dss', publicKeyData: publicKeyData, privateKeyComponents: { p, q, g, y, x }, comment: comment, passphrase: this.options.outputPassphrase }); } else { // Legacy PEM format privateKeyPem = this.createDSAPrivateKeyPEM(p, q, g, y, x); } return { privateKey: privateKeyPem, publicKey: publicKeySSH, fingerprint: this.generateFingerprint(publicKeyData) }; } /** * Create DSA private key in PEM format */ createDSAPrivateKeyPEM(p, q, g, y, x) { const privateKeyDer = this.createDSAPrivateKeyDER(p, q, g, y, x); const base64Data = privateKeyDer.toString('base64'); const base64Lines = base64Data.match(/.{1,64}/g) || [base64Data]; return '-----BEGIN DSA PRIVATE KEY-----\n' + base64Lines.join('\n') + '\n-----END DSA PRIVATE KEY-----\n'; } /** * Convert ECDSA key to OpenSSH format */ async convertECDSAToOpenSSH(algorithm, publicKeyData, privateKeyData, comment, outputFormat = 'pem') { // Parse public key components const pubReader = new BinaryReader(publicKeyData); const keyType = pubReader.readString(); const curveName = pubReader.readString(); const publicPoint = pubReader.readBuffer(); // Parse private key components const privReader = new BinaryReader(privateKeyData); const privateScalar = privReader.readBuffer(); // Map curve names to OIDs const curveOIDs = { 'nistp256': '1.2.840.10045.3.1.7', // prime256v1 'nistp384': '1.3.132.0.34', // secp384r1 'nistp521': '1.3.132.0.35' // secp521r1 }; const curve = curveName.replace('nistp', 'P-'); const oid = curveOIDs[curveName]; // Create OpenSSH public key const publicKeySSH = `${keyType} ${publicKeyData.toString('base64')} ${comment}`; let privateKeyPem; if (outputFormat === 'openssh') { // Create OpenSSH private key format for ECDSA privateKeyPem = this.createOpenSSHPrivateKey({ keyType: keyType, publicKeyData, privateKeyComponents: { curveName, privateScalar, publicPoint }, comment, passphrase: this.options.outputPassphrase }); } else { // Legacy PEM format privateKeyPem = this.createECPrivateKeyPEM(oid, privateScalar, publicPoint); } return { privateKey: privateKeyPem, publicKey: publicKeySSH, fingerprint: this.generateFingerprint(publicKeyData), curve: curve }; } /** * Create EC private key in PEM format */ createECPrivateKeyPEM(oid, privateScalar, publicPoint) { const privateKeyDer = this.createECPrivateKeyDER(oid, privateScalar, publicPoint); const base64Data = privateKeyDer.toString('base64'); const base64Lines = base64Data.match(/.{1,64}/g) || [base64Data]; return '-----BEGIN EC PRIVATE KEY-----\n' + base64Lines.join('\n') + '\n-----END EC PRIVATE KEY-----\n'; } /** * Convert Ed25519 key to OpenSSH format */ convertEd25519ToOpenSSH(publicKeyData, privateKeyData, comment) { // Parse the public key const reader = new BinaryReader(publicKeyData); const _keyType = reader.readString(); const publicKey = reader.readBuffer(); // For Ed25519, the private key in PPK contains only the private key (32 bytes) const privReader = new BinaryReader(privateKeyData); const privateKey = privReader.readBuffer(); // Create OpenSSH private key format using common method const privateKeyPem = this.createOpenSSHPrivateKey({ keyType: 'ssh-ed25519', publicKeyData, privateKeyComponents: { privateKey, publicKey }, comment, passphrase: this.options.outputPassphrase }); const publicKeySSH = `ssh-ed25519 ${publicKeyData.toString('base64')} ${comment}`; return { privateKey: privateKeyPem, publicKey: publicKeySSH, fingerprint: this.generateFingerprint(publicKeyData) }; } /** * Create OpenSSH private key format - common method for all key types */ createOpenSSHPrivateKey({ keyType, publicKeyData, privateKeyComponents, comment, passphrase = null }) { const auth = passphrase ? 'aes256-ctr' : 'none'; const kdf = passphrase ? 'bcrypt' : 'none'; const checkInt = crypto.randomBytes(4); let privateKeyBuffer; if (keyType === 'ssh-ed25519') { // Ed25519 specific encoding privateKeyBuffer = Buffer.concat([privateKeyComponents.privateKey, privateKeyComponents.publicKey]); } else if (keyType === 'ssh-rsa') { // RSA specific encoding - SSH wire format const { n, e, d, iqmp, p, q } = privateKeyComponents; privateKeyBuffer = Buffer.concat([ this.encodeBuffer(n), this.encodeBuffer(e), this.encodeBuffer(d), this.encodeBuffer(iqmp), this.encodeBuffer(p), this.encodeBuffer(q) ]); } else if (keyType.startsWith('ecdsa-sha2-')) { // ECDSA specific encoding - SSH wire format const { curveName, privateScalar, publicPoint } = privateKeyComponents; privateKeyBuffer = Buffer.concat([ this.encodeBuffer(Buffer.from(curveName)), this.encodeBuffer(publicPoint), this.encodeBuffer(privateScalar) ]); } else if (keyType === 'ssh-dss') { // DSA specific encoding - SSH wire format const { p, q, g, y, x } = privateKeyComponents; privateKeyBuffer = Buffer.concat([ this.encodeBuffer(p), this.encodeBuffer(q), this.encodeBuffer(g), this.encodeBuffer(y), this.encodeBuffer(x) ]); } // Extract public key components (skip the key type prefix) const pubReader = new BinaryReader(publicKeyData); const _pubKeyType = pubReader.readString(); let publicKeyComponents; if (keyType === 'ssh-ed25519') { publicKeyComponents = pubReader.readBuffer(); } else if (keyType === 'ssh-rsa') { const e = pubReader.readBuffer(); const n = pubReader.readBuffer(); publicKeyComponents = Buffer.concat([ this.encodeBuffer(e), this.encodeBuffer(n) ]); } else if (keyType.startsWith('ecdsa-sha2-')) { const curveName = pubReader.readString(); const publicPoint = pubReader.readBuffer(); publicKeyComponents = Buffer.concat([ this.encodeBuffer(Buffer.from(curveName)), this.encodeBuffer(publicPoint) ]); } else if (keyType === 'ssh-dss') { const p = pubReader.readBuffer(); const q = pubReader.readBuffer(); const g = pubReader.readBuffer(); const y = pubReader.readBuffer(); publicKeyComponents = Buffer.concat([ this.encodeBuffer(p), this.encodeBuffer(q), this.encodeBuffer(g), this.encodeBuffer(y) ]); } // Build the key data const keyData = Buffer.concat([ checkInt, checkInt, this.encodeBuffer(Buffer.from(keyType)), this.encodeBuffer(publicKeyComponents), this.encodeBuffer(privateKeyBuffer), this.encodeBuffer(Buffer.from(comment || '')) ]); // Add padding const blockSize = 8; const paddingLength = blockSize - (keyData.length % blockSize); const padding = Buffer.alloc(paddingLength); for (let i = 0; i < paddingLength; i++) { padding[i] = i + 1; } let finalKeyData = Buffer.concat([keyData, padding]); let kdfOptions = Buffer.from(''); // Handle encryption if passphrase is provided if (passphrase) { const salt = crypto.randomBytes(16); const rounds = 16; // bcrypt rounds // Create KDF options for bcrypt kdfOptions = Buffer.concat([ this.encodeBuffer(salt), Buffer.from([0, 0, 0, rounds]) ]); // Derive key using bcrypt-style key derivation const keyIv = this.deriveKeyIv(passphrase, salt, rounds, 48); // 32 for key + 16 for IV const key = keyIv.slice(0, 32); const iv = keyIv.slice(32, 48); // Encrypt the key data const cipher = crypto.createCipheriv('aes-256-ctr', key, iv); finalKeyData = Buffer.concat([cipher.update(finalKeyData), cipher.final()]); } // Create the OpenSSH private key structure const privateKeyStructure = Buffer.concat([ Buffer.from('openssh-key-v1\0'), this.encodeBuffer(Buffer.from(auth)), this.encodeBuffer(Buffer.from(kdf)), this.encodeBuffer(kdfOptions), Buffer.from([0, 0, 0, 1]), // number of keys this.encodeBuffer(publicKeyData), this.encodeBuffer(finalKeyData) ]); const base64Data = privateKeyStructure.toString('base64'); const base64Lines = base64Data.match(/.{1,70}/g) || [base64Data]; return '-----BEGIN OPENSSH PRIVATE KEY-----\n' + base64Lines.join('\n') + '\n-----END OPENSSH PRIVATE KEY-----\n'; } /** * Create RSA private key in PKCS#1 DER format */ createRSAPrivateKeyDER(n, e, d, p, q, dP, dQ, qInv) { // RSAPrivateKey ::= SEQUENCE { // version Version, // modulus INTEGER, -- n // publicExponent INTEGER, -- e // privateExponent INTEGER, -- d // prime1 INTEGER, -- p // prime2 INTEGER, -- q // exponent1 INTEGER, -- dP // exponent2 INTEGER, -- dQ // coefficient INTEGER -- qInv // } const version = Buffer.from([0x02, 0x01, 0x00]); // INTEGER 0 const sequence = Buffer.concat([ version, this.encodeInteger(n), this.encodeInteger(e), this.encodeInteger(d), this.encodeInteger(p), this.encodeInteger(q), this.encodeInteger(this.bigIntToBuffer(dP)), this.encodeInteger(this.bigIntToBuffer(dQ)), this.encodeInteger(qInv) ]); // Wrap in SEQUENCE return this.encodeSequence(sequence); } /** * Create DSA private key in DER format */ createDSAPrivateKeyDER(p, q, g, y, x) { // DSAPrivateKey ::= SEQUENCE { // version INTEGER, // p INTEGER, // q INTEGER, // g INTEGER, // y INTEGER, // x INTEGER // } const version = Buffer.from([0x02, 0x01, 0x00]); // INTEGER 0 const sequence = Buffer.concat([ version, this.encodeInteger(p), this.encodeInteger(q), this.encodeInteger(g), this.encodeInteger(y), this.encodeInteger(x) ]); return this.encodeSequence(sequence); } /** * Create EC private key in SEC1 DER format */ createECPrivateKeyDER(curveOID, privateKey, publicKey) { // ECPrivateKey ::= SEQUENCE { // version INTEGER { ecPrivkeyVer1(1) }, // privateKey OCTET STRING, // parameters [0] EXPLICIT ECDomainParameters OPTIONAL, // publicKey [1] EXPLICIT BIT STRING OPTIONAL // } const version = Buffer.from([0x02, 0x01, 0x01]); // INTEGER 1 // Encode private key as OCTET STRING const privateKeyOctet = Buffer.concat([ Buffer.from([0x04]), // OCTET STRING tag this.encodeLength(privateKey.length), privateKey ]); // Encode curve OID with context tag [0] const oidEncoded = this.encodeOID(curveOID); const parameters = Buffer.concat([ Buffer.from([0xa0]), // Context tag [0] this.encodeLength(oidEncoded.length), oidEncoded ]); // Encode public key with context tag [1] const publicKeyBitString = Buffer.concat([ Buffer.from([0x03]), // BIT STRING tag this.encodeLength(publicKey.length + 1), Buffer.from([0x00]), // No unused bits publicKey ]); const publicKeyTagged = Buffer.concat([ Buffer.from([0xa1]), // Context tag [1] this.encodeLength(publicKeyBitString.length), publicKeyBitString ]); const sequence = Buffer.concat([ version, privateKeyOctet, parameters, publicKeyTagged ]); return this.encodeSequence(sequence); } /** * ASN.1 DER encoding helpers */ encodeInteger(buffer) { // Add leading zero if high bit is set (to indicate positive number) const needsLeadingZero = buffer[0] & 0x80; const content = needsLeadingZero ? Buffer.concat([Buffer.from([0x00]), buffer]) : buffer; return Buffer.concat([ Buffer.from([0x02]), // INTEGER tag this.encodeLength(content.length), content ]); } encodeSequence(content) { return Buffer.concat([ Buffer.from([0x30]), // SEQUENCE tag this.encodeLength(content.length), content ]); } encodeOID(oidString) { const parts = oidString.split('.').map(s => parseInt(s)); const bytes = []; // First byte combines first two numbers bytes.push(parts[0] * 40 + parts[1]); // Encode remaining numbers for (let i = 2; i < parts.length; i++) { const num = parts[i]; if (num < 128) { bytes.push(num); } else { // Multi-byte encoding const temp = []; let n = num; while (n > 0) { temp.unshift(n & 0x7f); n = n >> 7; } for (let j = 0; j < temp.length - 1; j++) { bytes.push(temp[j] | 0x80); } bytes.push(temp[temp.length - 1]); } } return Buffer.concat([ Buffer.from([0x06]), // OID tag this.encodeLength(bytes.length), Buffer.from(bytes) ]); } encodeLength(length) { if (length < 128) { return Buffer.from([length]); } // Long form const bytes = []; let temp = length; while (temp > 0) { bytes.unshift(temp & 0xff); temp = temp >> 8; } return Buffer.concat([ Buffer.from([0x80 | bytes.length]), Buffer.from(bytes) ]); } /** * Buffer/BigInt conversion utilities */ bufferToBigInt(buffer) { let hex = '0x'; for (const byte of buffer) { hex += byte.toString(16).padStart(2, '0'); } return BigInt(hex); } bigIntToBuffer(bigint) { let hex = bigint.toString(16); if (hex.length % 2) hex = '0' + hex; const bytes = []; for (let i = 0; i < hex.length; i += 2) { bytes.push(parseInt(hex.substr(i, 2), 16)); } return Buffer.from(bytes); } /** * Parse SSH public key format */ parseSSHPublicKey(data) { const reader = new BinaryReader(data); const keyType = reader.readString(); if (keyType === 'ssh-rsa') { const e = reader.readBuffer(); const n = reader.readBuffer(); return { keyType, e, n }; } throw new Error(`Unsupported key type in public key: ${keyType}`); } /** * Create a ssh2-streams compatible key with modern signature algorithm * This method generates a private key that ssh2-streams can parse and then * modifies its signature algorithm for compatibility with modern SSH servers */ async createSSH2StreamsCompatibleKey(privateKey, signatureAlgorithm = 'sha256') { try { // Only apply this enhancement for RSA keys that need signature algorithm upgrades // Check if this is an RSA key by looking at the key header if (!privateKey.includes('BEGIN RSA PRIVATE KEY') && !privateKey.includes('ssh-rsa')) { // For non-RSA keys, just parse normally since they don't need signature algorithm fixes const ssh2Streams = require('ssh2-streams'); return ssh2Streams.utils.parseKey(privateKey); } // For RSA keys, we need to override the signature algorithm const ssh2Streams = require('ssh2-streams'); // Parse the RSA key with ssh2-streams const parsedKey = ssh2Streams.utils.parseKey(privateKey); if (!parsedKey || typeof parsedKey.getPublicSSH !== 'function') { throw new Error('Key could not be parsed by ssh2-streams'); } // Only modify signature algorithm for RSA keys (ssh-rsa type) if (parsedKey.type === 'ssh-rsa') { // Find and modify the hash algorithm symbol to upgrade from sha1 const hashAlgSymbol = Object.getOwnPropertySymbols(parsedKey) .find(s => s.toString().includes('Hash Algorithm')); if (hashAlgSymbol) { const currentAlg = parsedKey[hashAlgSymbol]; // Only upgrade if it's currently using sha1 (the ssh2-streams default) if (currentAlg === 'sha1') { parsedKey[hashAlgSymbol] = signatureAlgorithm; } } } return parsedKey; } catch (error) { if (error.code === 'MODULE_NOT_FOUND' && error.message.includes('ssh2-streams')) { throw new Error('ssh2-streams module not available - compatibility method not supported'); } throw new Error(`Failed to create ssh2-streams compatible key: ${error.message}`); } } /** * Generate SSH fingerprint */ generateFingerprint(publicKeyData) { const hash = crypto.createHash('sha256'); hash.update(publicKeyData); const digest = hash.digest(); return 'SHA256:' + digest.toString('base64').replace(/=+$/, ''); } /** * Derive key and IV using bcrypt-style derivation for OpenSSH format */ deriveKeyIv(passphrase, salt, rounds, keyLength) { // OpenSSH uses a simplified bcrypt-like derivation // This is a simplified version that should work for most cases let derived = Buffer.alloc(0); let counter = 1; while (derived.length < keyLength) { const hash = crypto.createHash('sha1'); hash.update(salt); hash.update(Buffer.from(passphrase, 'utf8')); hash.update(Buffer.from([counter])); const chunk = hash.digest(); derived = Buffer.concat([derived, chunk]); counter++; } return derived.slice(0, keyLength); } /** * Encode buffer with length prefix */ encodeBuffer(buffer) { const length = Buffer.allocUnsafe(4); length.writeUInt32BE(buffer.length, 0); return Buffer.concat([length, buffer]); } } /** * Binary data reader helper class with bounds checking */ class BinaryReader { constructor(buffer) { this.buffer = buffer; this.offset = 0; this.maxFieldSize = 1024 * 1024; // 1MB per field } checkBounds(length) { if (this.offset + length > this.buffer.length) { throw new PPKError( 'Invalid PPK data: buffer underrun', 'BUFFER_UNDERRUN', { offset: this.offset, requested: length, available: this.buffer.length - this.offset } ); } } readUInt32() { this.checkBounds(4); const value = this.buffer.readUInt32BE(this.offset); this.offset += 4; return value; } readString() { const length = this.readUInt32(); if (length > this.maxFieldSize) { throw new PPKError( `Invalid PPK data: field too large (${length} bytes)`, 'FIELD_TOO_LARGE', { length: length, max: this.maxFieldSize } ); } this.checkBounds(length); const value = this.buffer.toString('utf8', this.offset, this.offset + length); this.offset += length; return value; } readBuffer() { const length = this.readUInt32(); if (length > this.maxFieldSize) { throw new PPKError( `Invalid PPK data: field too large (${length} bytes)`, 'FIELD_TOO_LARGE', { length: length, max: this.maxFieldSize } ); } this.checkBounds(length); const value = this.buffer.slice(this.offset, this.offset + length); this.offset += length; return value; } readBytes(length) { this.checkBounds(length); const value = this.buffer.slice(this.offset, this.offset + length); this.offset += length; return value; } } // Export the parser and error class module.exports = PPKParser; module.exports.default = module.exports; module.exports.PPKParser = PPKParser; module.exports.PPKError = PPKError;