pure-js-sftp
Version:
A pure JavaScript SFTP client with revolutionary RSA-SHA2 compatibility fixes. Zero native dependencies, built on ssh2-streams with 100% SSH key support.
387 lines • 17.1 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.SSH2StreamsTransport = void 0;
const events_1 = require("events");
const net_1 = require("net");
const ssh2Streams = __importStar(require("ssh2-streams"));
const revolutionary_proxy_fix_1 = require("./revolutionary-proxy-fix");
const enhanced_key_parser_1 = require("./enhanced-key-parser");
const rsa_sha2_wrapper_1 = require("./rsa-sha2-wrapper");
class SSH2StreamsTransport extends events_1.EventEmitter {
constructor(config) {
super();
this.connected = false;
this.authenticated = false;
this.sftpChannel = null;
this.pendingChannels = new Map();
this.config = config;
this.socket = new net_1.Socket();
// Use modern SSH algorithms compatible with OpenSSH 8.0+
const defaultAlgorithms = {
kex: [
'ecdh-sha2-nistp256',
'ecdh-sha2-nistp384',
'ecdh-sha2-nistp521',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512',
'diffie-hellman-group18-sha512',
// Fallback for ssh2-streams compatibility
'diffie-hellman-group14-sha1'
],
cipher: [
'aes128-gcm@openssh.com',
'aes256-gcm@openssh.com',
'aes128-ctr',
'aes192-ctr',
'aes256-ctr',
// Fallback ciphers
'aes128-cbc',
'aes256-cbc'
],
hmac: [
'hmac-sha2-256',
'hmac-sha2-512',
// Fallback MAC for compatibility
'hmac-sha1'
],
compress: ['none']
};
// Create SSH2Stream instance
const originalSSH = new ssh2Streams.SSH2Stream({
server: false,
algorithms: config.algorithms || defaultAlgorithms
});
// Apply RSA-SHA2 proxy fix only when needed (RSA keys with modern SSH servers)
let needsRSAFix = false;
if (config.privateKey) {
try {
// Check if this is an RSA key that might need the revolutionary fix
const parsedKey = ssh2Streams.utils.parseKey(config.privateKey, config.passphrase);
if (parsedKey) {
const key = Array.isArray(parsedKey) ? parsedKey[0] : parsedKey;
if (key && key.type === 'ssh-rsa') {
needsRSAFix = true;
this.emit('debug', 'RSA key detected - revolutionary proxy fix will be applied for modern SSH server compatibility');
}
else {
this.emit('debug', `${key?.type || 'Unknown'} key detected - no RSA-SHA2 fix needed`);
}
}
}
catch (keyCheckError) {
this.emit('debug', `Key type detection failed: ${keyCheckError instanceof Error ? keyCheckError.message : String(keyCheckError)} - will apply proxy as fallback`);
// If we can't determine the key type, apply the proxy as a safety measure
needsRSAFix = true;
}
}
else {
this.emit('debug', 'Password authentication - no RSA-SHA2 fix needed');
}
if (needsRSAFix) {
this.ssh = (0, revolutionary_proxy_fix_1.applyRevolutionaryProxyFix)(originalSSH, (msg) => this.emit('debug', msg));
}
else {
this.ssh = originalSSH;
}
this.setupEventHandlers();
}
setupEventHandlers() {
// Socket events
this.socket.on('connect', () => {
this.emit('debug', 'Socket connected');
});
this.socket.on('error', (err) => {
this.emit('error', err);
});
this.socket.on('close', () => {
this.connected = false;
this.authenticated = false;
this.emit('close');
});
// SSH events
this.ssh.on('ready', () => {
this.emit('debug', 'SSH handshake complete, requesting ssh-userauth');
this.ssh.service('ssh-userauth');
});
this.ssh.on('SERVICE_ACCEPT', (serviceName) => {
this.emit('debug', `Service accepted: ${serviceName}`);
if (serviceName === 'ssh-userauth') {
this.startAuthentication();
}
});
this.ssh.on('USERAUTH_SUCCESS', () => {
this.emit('debug', 'Authentication successful');
this.authenticated = true;
this.connected = true;
this.emit('ready');
});
this.ssh.on('USERAUTH_FAILURE', (methods, _partial) => {
this.emit('error', new Error(`Authentication failed. Available methods: ${methods.join(', ')}`));
});
this.ssh.on('USERAUTH_BANNER', (_message) => {
this.emit('debug', 'Server banner received');
});
// Channel events
this.ssh.on('CHANNEL_OPEN_CONFIRMATION:0', (info) => {
this.emit('debug', `Channel opened successfully: ${JSON.stringify(info)}`);
// Adjust channel window size if it's 0
if (info.window === 0) {
this.emit('debug', 'Channel window size is 0, adjusting to 65536 bytes');
this.ssh.channelWindowAdjust(0, 65536);
}
// Create channel wrapper immediately and store it
const channel = this.createChannelWrapper(info);
this.sftpChannel = channel;
const callback = this.pendingChannels.get(0);
if (callback) {
callback(undefined, channel);
this.pendingChannels.delete(0);
}
else {
this.emit('debug', 'WARNING: No pending callback for channel 0');
}
});
this.ssh.on('CHANNEL_SUCCESS:0', () => {
this.emit('debug', 'SFTP subsystem started successfully');
// Also adjust window here to ensure we can receive SFTP data
this.emit('debug', 'Adjusting window size after SFTP subsystem start');
this.ssh.channelWindowAdjust(0, 32768);
});
this.ssh.on('CHANNEL_FAILURE:0', () => {
this.emit('error', new Error('SFTP subsystem request failed'));
});
this.ssh.on('CHANNEL_DATA:0', (data) => {
this.emit('debug', `Received CHANNEL_DATA:0: ${data.length} bytes`);
if (this.sftpChannel) {
this.sftpChannel.emit('data', data);
}
else {
this.emit('debug', 'WARNING: Received channel data but no SFTP channel to forward to');
}
});
this.ssh.on('CHANNEL_CLOSE:0', () => {
if (this.sftpChannel) {
this.sftpChannel.emit('close');
}
});
this.ssh.on('GLOBAL_REQUEST', () => {
// Ignore global requests like host key announcements
});
this.ssh.on('error', (err) => {
this.emit('error', err);
});
this.ssh.on('close', () => {
this.emit('close');
});
// Standard SSH stream piping - RSA-SHA2 fix is applied via proxy
this.socket.pipe(this.ssh).pipe(this.socket);
}
startAuthentication() {
if (this.config.password) {
this.emit('debug', 'Starting password authentication');
this.ssh.authPassword(this.config.username, this.config.password);
}
else if (this.config.privateKey) {
this.emit('debug', 'Starting public key authentication');
try {
let parsedKey = null;
// First try ssh2-streams parser
try {
this.emit('debug', 'Attempting ssh2-streams key parsing');
const parsedKeys = ssh2Streams.utils.parseKey(this.config.privateKey, this.config.passphrase);
// Check if parsing was successful
if (parsedKeys && (Array.isArray(parsedKeys) ? parsedKeys.length > 0 : true)) {
const key = Array.isArray(parsedKeys) ? parsedKeys[0] : parsedKeys;
if (key && typeof key.getPublicSSH === 'function') {
parsedKey = key;
this.emit('debug', 'ssh2-streams key parsing successful');
}
}
}
catch (ssh2StreamsError) {
this.emit('debug', `ssh2-streams parsing failed: ${ssh2StreamsError instanceof Error ? ssh2StreamsError.message : String(ssh2StreamsError)}`);
}
// If ssh2-streams failed, try our enhanced key parser
if (!parsedKey) {
this.emit('debug', 'Falling back to enhanced key parser');
parsedKey = (0, enhanced_key_parser_1.parseKey)(this.config.privateKey, this.config.passphrase);
if (parsedKey) {
this.emit('debug', 'Enhanced key parsing successful');
}
else {
throw new Error('Key parsing failed: Enhanced parser with sshpk fallback could not parse the provided key');
}
}
const publicKeySSH = parsedKey.getPublicSSH();
this.emit('debug', `Public key SSH format generated: ${publicKeySSH.length} bytes`);
// Use RSA-SHA2 signature wrapper for RSA keys to ensure modern cryptographic signatures
let signatureCallback;
if (parsedKey.type === 'ssh-rsa') {
this.emit('debug', 'Using RSA-SHA2 signature wrapper');
try {
signatureCallback = (0, rsa_sha2_wrapper_1.createRSASHA2SignatureCallback)(parsedKey, this.config.privateKey, this.config.passphrase, 'sha256');
}
catch (wrapperError) {
this.emit('debug', `RSA-SHA2 wrapper failed, using original signing: ${wrapperError instanceof Error ? wrapperError.message : String(wrapperError)}`);
signatureCallback = (buf, cb) => {
try {
const signature = parsedKey.sign(buf);
cb(signature);
}
catch (error) {
throw new Error(`Signing failed: ${error instanceof Error ? error.message : String(error)}`);
}
};
}
}
else {
// Use original signing for non-RSA keys (Ed25519, ECDSA)
this.emit('debug', `Using original signing for ${parsedKey.type} key`);
signatureCallback = (buf, cb) => {
try {
const signature = parsedKey.sign(buf);
cb(signature);
}
catch (error) {
throw new Error(`Signing failed: ${error instanceof Error ? error.message : String(error)}`);
}
};
}
// Authenticate with public key using appropriate signature method
this.ssh.authPK(this.config.username, publicKeySSH, (buf, cb) => {
try {
this.emit('debug', `Generating signature for ${buf.length} bytes of challenge data`);
signatureCallback(buf, (signature) => {
this.emit('debug', `Signature generated: ${signature?.length || 'undefined'} bytes`);
cb(signature);
});
}
catch (error) {
this.emit('error', new Error(`Authentication signing failed: ${error instanceof Error ? error.message : String(error)}`));
}
});
}
catch (error) {
this.emit('error', new Error(`Key parsing failed: ${error instanceof Error ? error.message : String(error)}`));
}
}
else {
this.emit('error', new Error('No authentication method provided: must provide either password or privateKey'));
}
}
createChannelWrapper(info) {
const channelEmitter = new events_1.EventEmitter();
// Extend EventEmitter with our methods
const channel = Object.assign(channelEmitter, {
write: (data) => {
this.emit('debug', `Sending channel data: ${data.length} bytes`);
this.ssh.channelData(info.recipient, data);
},
end: () => {
this.ssh.channelClose(info.recipient);
}
});
return channel;
}
connect() {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Connection timeout'));
}, 30000);
this.once('ready', () => {
clearTimeout(timeout);
resolve();
});
this.once('error', (err) => {
clearTimeout(timeout);
reject(err);
});
// Connect to server
this.socket.connect(this.config.port || 22, this.config.host);
});
}
async openSFTP() {
if (!this.connected || !this.authenticated) {
throw new Error('Not connected or authenticated');
}
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('SFTP open timeout'));
}, 15000);
// Use channel 0 for session (ssh2-streams default)
const channelId = 0;
this.pendingChannels.set(channelId, (err, channel) => {
clearTimeout(timeout);
if (err) {
reject(err);
return;
}
if (!channel) {
reject(new Error('No channel received'));
return;
}
// Request SFTP subsystem
this.ssh.subsystem(0, 'sftp', true);
// Wait for subsystem success
const successHandler = () => {
this.ssh.removeListener('CHANNEL_FAILURE:0', failureHandler);
resolve(channel);
};
const failureHandler = () => {
this.ssh.removeListener('CHANNEL_SUCCESS:0', successHandler);
reject(new Error('SFTP subsystem request failed'));
};
this.ssh.once('CHANNEL_SUCCESS:0', successHandler);
this.ssh.once('CHANNEL_FAILURE:0', failureHandler);
});
// Open session channel with proper window size and packet size
this.ssh.session(0, 65536, 32768);
});
}
disconnect() {
if (this.socket && !this.socket.destroyed) {
this.socket.destroy();
}
this.connected = false;
this.authenticated = false;
}
isConnected() {
return this.connected && this.authenticated;
}
}
exports.SSH2StreamsTransport = SSH2StreamsTransport;
//# sourceMappingURL=ssh2-streams-transport.js.map