Skip to content

Commit

Permalink
[DRAFT] REFACTOR: encrypted key support for ssv modal
Browse files Browse the repository at this point in the history
  • Loading branch information
daverolo committed Mar 12, 2024
1 parent c531dc2 commit 34b49a1
Show file tree
Hide file tree
Showing 11 changed files with 1,608 additions and 190 deletions.
5 changes: 3 additions & 2 deletions launcher/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "stereum-launcher",
"version": "2.0.4",
"version": "2.1.0",
"private": true,
"description": "Stereum Ethereum Node Setup Launcher",
"author": "stereum.net",
Expand Down Expand Up @@ -45,6 +45,7 @@
"leader-line-new": "^1.1.9",
"pinia": "^2.0.33",
"qrcode": "^1.5.1",
"semver": "^7.6.0",
"ssh2": "^1.1.0",
"swiper": "^11.0.6",
"vue": "^3.2.33",
Expand Down Expand Up @@ -92,4 +93,4 @@
"type": "git",
"url": "[email protected]:stereum-dev/ethereum-node.git"
}
}
}
758 changes: 714 additions & 44 deletions launcher/src/backend/NodeConnection.js

Large diffs are not rendered by default.

111 changes: 59 additions & 52 deletions launcher/src/backend/SSHService.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export class SSHService {
}, 100);
}

static checkExecError(err) {
static checkExecError(err, accept_empty_result = false) {
if (accept_empty_result) return err.rc != 0;
return !err || err.rc != 0;
}

Expand Down Expand Up @@ -62,8 +63,12 @@ export class SSHService {
let lastIndex = this.connectionPool.length - 1;
const threshholdIndex = lastIndex - 2;

if (this.connectionInfo && !this.addingConnection && (this.connectionPool.length < 6 || this.connectionPool[threshholdIndex]?._chanMgr?._count > 0)) {
await this.connect(this.connectionInfo)
if (
this.connectionInfo &&
!this.addingConnection &&
(this.connectionPool.length < 6 || this.connectionPool[threshholdIndex]?._chanMgr?._count > 0)
) {
await this.connect(this.connectionInfo);
}
if (this.connectionPool.length > 5 && this.connectionPool[threshholdIndex]?._chanMgr?._count === 0) {
this.removeConnectionCount++;
Expand Down Expand Up @@ -302,12 +307,12 @@ export class SSHService {
const keyPair = generateKeyPairSync(opts.keyType, opts);
let exitingKeys = await this.readSSHKeyFile();
if (keyPair.public) {
let allKeys = [...exitingKeys, keyPair.public]
await this.writeSSHKeyFile(allKeys)
const savePath = path.join(opts.pickPath, opts.keyType.toLowerCase())
await fs.promises.writeFile(savePath, keyPair.private)
await fs.promises.writeFile(savePath + ".pub", keyPair.public)
return allKeys
let allKeys = [...exitingKeys, keyPair.public];
await this.writeSSHKeyFile(allKeys);
const savePath = path.join(opts.pickPath, opts.keyType.toLowerCase());
await fs.promises.writeFile(savePath, keyPair.private);
await fs.promises.writeFile(savePath + ".pub", keyPair.public);
return allKeys;
}
return exitingKeys;
} catch (err) {
Expand All @@ -316,7 +321,7 @@ export class SSHService {
}

async readSSHKeyFile(sshDirPath = `~/.ssh`) {
let authorizedKeys = []
let authorizedKeys = [];
try {
if (sshDirPath.endsWith("/")) sshDirPath = sshDirPath.slice(0, -1, ""); //if path ends with '/' remove it
let result = await this.exec(`cat ${sshDirPath}/authorized_keys`);
Expand All @@ -335,8 +340,10 @@ export class SSHService {
async writeSSHKeyFile(keys = [], sshDirPath = `~/.ssh`) {
try {
if (sshDirPath.endsWith("/")) sshDirPath = sshDirPath.slice(0, -1, ""); //if path ends with '/' remove it
let newKeys = keys.join("\n")
let result = await this.exec(`echo -e ${StringUtils.escapeStringForShell(newKeys)} > ${sshDirPath}/authorized_keys`);
let newKeys = keys.join("\n");
let result = await this.exec(
`echo -e ${StringUtils.escapeStringForShell(newKeys)} > ${sshDirPath}/authorized_keys`
);
if (SSHService.checkExecError(result)) {
throw new Error("Failed writing authorized keys:\n" + SSHService.extractExecError(result));
}
Expand All @@ -351,7 +358,7 @@ export class SSHService {
* Checks if mode is a directory with fs constants https://nodejs.org/api/fs.html#fsconstants.
* `S_IFMT` is a bit mask used to extract the file type code.
* `S_IFDIR` is a file type constant for a directory.
* @param {Integer} mode
* @param {Integer} mode
* @returns `True` if mode is a directory, `False` otherwise
*/
isDir(mode) {
Expand All @@ -361,11 +368,11 @@ export class SSHService {
/**
* Get an SFTP session object from the connection pool.
* Optionally takes a ssh session object as an argument, otherwise it will get a new connection from the pool.
* @param {Client} [conn]
* @param {Client} [conn]
* @returns sftp session object
*/
async getSFTPSession(conn = null) {
conn = this.getConnectionFromPool()
conn = this.getConnectionFromPool();
return new Promise((resolve, reject) => {
conn.sftp((err, sftp) => {
if (err) {
Expand All @@ -379,8 +386,8 @@ export class SSHService {

/**
* Reads a directory's contents from remotePath using SFTP
* @param {String} remotePath
* @param {SFTP Session} [sftp]
* @param {String} remotePath
* @param {SFTP Session} [sftp]
* @returns Array of objects containing filename and mode of the contents of a given directory on the remote server.
*/
async readDirectorySFTP(remotePath, sftp = null) {
Expand All @@ -401,23 +408,23 @@ export class SSHService {
/**
* Returns an array of objects containing filename and mode of the contents of a given directory on the remote server.
* Workaround for readdir not running with sudo permissions.
* @param {String} remotePath
* @returns
* @param {String} remotePath
* @returns
*/
async readDirectorySSH(remotePath) {
try {
const result = await this.exec(`find ${remotePath} -maxdepth 1 -exec stat --format '%n\n%f\n' {} +`);
if (SSHService.checkExecError(result)) {
throw new Error("Failed reading directory: " + SSHService.extractExecError(result));
}
let files = result.stdout.split("\n\n").filter((e) => e)
let files = result.stdout.split("\n\n").filter((e) => e);
files.shift(); //remove the first element which is the directory itself
return files.map((file) => {
let [filename, mode] = file.split("\n");
filename = path.posix.basename(path.posix.normalize(filename)); // normalize path
mode = parseInt(mode, 16); // convert mode from hex to integer
return { filename, mode };
})
});
} catch (error) {
log.error("Failed reading directory via SSH: ", error);
return [];
Expand All @@ -426,14 +433,14 @@ export class SSHService {

/**
* Reads a directory's contents from localPath
* @param {String} localPath
* @param {String} localPath
* @returns Array of Dirent objects or an empty array on error
*/
async readDirectoryLocal(localPath) {
try {
log.info("localPath", localPath)
log.info("localPath", localPath);
const filenames = await fs.promises.readdir(localPath, { withFileTypes: true });
log.info("filenames", filenames)
log.info("filenames", filenames);
return filenames;
} catch (error) {
console.error("Failed reading local directory: ", error);
Expand All @@ -445,30 +452,30 @@ export class SSHService {
* Downloads a file from remotePath to localPath.
* Uses "cat" to get file contents and pipes that stream to a local write stream.
* This is a workaround for the lack of sudo permissions with sftp createReadStream.
* @param {String} remotePath
* @param {String} localPath
* @param {Client} [conn]
* @param {String} remotePath
* @param {String} localPath
* @param {Client} [conn]
* @returns `void`
*/
async downloadFileSSH(remotePath, localPath, conn = this.getConnectionFromPool()) {
return new Promise((resolve, reject) => {
const writeStream = fs.createWriteStream(localPath);
writeStream.on('error', reject);
writeStream.on('close', resolve);
writeStream.on("error", reject);
writeStream.on("close", resolve);

conn.exec(`sudo cat ${remotePath}`, function (err, stream) {
if (err) throw err;
stream.on('error', reject);
stream.on("error", reject);
stream.pipe(writeStream);
});
});
}

/**
* Downloads a Directory and all its contents recursively from the remotePath to the localPath
* @param {String} remotePath
* @param {String} localPath
* @param {Client} [conn]
* @param {String} remotePath
* @param {String} localPath
* @param {Client} [conn]
* @returns `true` if download was successful, `false` otherwise
*/
async downloadDirectorySSH(remotePath, localPath, conn = null) {
Expand All @@ -481,7 +488,7 @@ export class SSHService {
fs.mkdirSync(localPath, { recursive: true });
}

const dirContents = await this.readDirectorySSH(remotePath)
const dirContents = await this.readDirectorySSH(remotePath);
for (let item of dirContents) {
const remoteFilePath = path.posix.join(remotePath, item.filename);
const localFilePath = path.join(localPath, item.filename);
Expand All @@ -492,37 +499,37 @@ export class SSHService {
await this.downloadFileSSH(remoteFilePath, localFilePath);
}
}
return true
return true;
} catch (error) {
log.error("Failed to download directory via SSH: ", error);
return false
return false;
}
}
/**
* Uploads a file from localPath to remotePath
* @param {String} localPath
* @param {String} remotePath
* @param {Client} [conn]
* @param {String} localPath
* @param {String} remotePath
* @param {Client} [conn]
* @returns `void`
*/
async uploadFileSSH(localPath, remotePath, conn = this.getConnectionFromPool()) {
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(localPath);
readStream.on('error', reject);
readStream.on('close', resolve);
readStream.on("error", reject);
readStream.on("close", resolve);

conn.exec(`sudo cat > ${remotePath}`, function (err, stream) {
if (err) throw err;
stream.on('error', reject);
stream.on('close', resolve);
stream.on("error", reject);
stream.on("close", resolve);
readStream.pipe(stream.stdin);
});
});
}
/**
* Ensures that the remotePath exists and is owned by the current user
* @param {String} remotePath
* @param {Client} [conn]
* @param {String} remotePath
* @param {Client} [conn]
*/
async ensureRemotePathExists(remotePath, conn = this.getConnectionFromPool()) {
return new Promise((resolve, reject) => {
Expand All @@ -535,9 +542,9 @@ export class SSHService {

/**
* Uploads a directory and all its contents recursively from the localPath to the remotePath
* @param {String} localPath
* @param {String} remotePath
* @param {Client} [conn]
* @param {String} localPath
* @param {String} remotePath
* @param {Client} [conn]
* @returns `true` if upload was successful, `false` otherwise
*/
async uploadDirectorySSH(localPath, remotePath, conn = null) {
Expand All @@ -546,9 +553,9 @@ export class SSHService {
conn = await this.getConnectionFromPool();
}

await this.ensureRemotePathExists(remotePath)
await this.ensureRemotePathExists(remotePath);

const dirContents = await this.readDirectoryLocal(localPath)
const dirContents = await this.readDirectoryLocal(localPath);
for (let item of dirContents) {
const remoteFilePath = path.posix.join(remotePath, item.name);
const localFilePath = path.join(localPath, item.name);
Expand All @@ -558,10 +565,10 @@ export class SSHService {
await this.uploadFileSSH(localFilePath, remoteFilePath);
}
}
return true
return true;
} catch (error) {
log.error("Failed to upload directory via SSH: ", error);
return false
return false;
}
}
}
80 changes: 80 additions & 0 deletions launcher/src/backend/StringUtils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
const log = require("electron-log");
import * as crypto from "crypto";
import { Base64 } from "@/share/Base64";

export class StringUtils {
static escapeStringForShell(shellCmd) {
Expand Down Expand Up @@ -27,4 +28,83 @@ export class StringUtils {
}
return result;
}

/**
* Returns true if str is base64 encoded
*/
static isBase64(str, enc = "utf8") {
return Base64.validate(str, enc);
}

/**
* Decode base64 encoded string
*/
static base64decode(str, enc = "utf8") {
return Base64.decode(str, enc);
}

/**
* Encode string to base64
*/
static base64encode(str, enc = "utf8") {
return Base64.encode(str, enc);
}

/**
* Get SSV public key from secret key
*/
static getSSVPublicKeyFromSecretKey(secret_key, raw = false, enc = "utf8") {
// Decode the base64 encoded SSV secret_key and get the RSA key in PEM format
const rsa_public_key = this.getRsaPublicKeyFromPrivateKey(secret_key, enc);

// RSA keys in PEM format that are generated by SSV are usually starting/ending
// with 'START RSA PUBLIC KEY' / ' END RSA PUBLIC KEY' while RSA keys generated
// via crypto lib start/end with 'START PUBLIC KEY' / ' END PUBLIC KEY'.
// Technically it does not matter but it is better to get the exact same base64
// encoded result as the user would get from SSV (to avoid confusion)
const rsa_public_key_ssv_style = rsa_public_key.replace(/(BEGIN|END) (PUBLIC.*)/g, "$1 RSA $2");

// Return the Base64 econded (or raw) RSA key
return !raw ? this.base64encode(rsa_public_key_ssv_style) : rsa_public_key_ssv_style;
}

/**
* Get RSA public key from private key
*/
static getRsaPublicKeyFromPrivateKey(private_key) {
try {
if (this.isBase64(private_key)) private_key = this.base64decode(private_key);

const pubKeyObject = crypto.createPublicKey({
key: private_key,
format: "pem",
});

const publicKey = pubKeyObject.export({
format: "pem",
type: "spki",
});

return publicKey;
} catch (err) {
log.error("Can't create RSA public key from RSA private key ", err);
throw new Error("Can't create RSA public key from RSA private key : " + err);
}
}

/**
* Return true if given public_key is a valid RSA public key in PEM format
*/
static isValidRsaPublicKey(public_key) {
//eturn crypto.verifyPublicKey(public_key, "pem", "rsa"); // true/false
return public_key.includes("PUBLIC KEY");
}

/**
* Return true if given private_key is a valid RSA private key in PEM format
*/
static isValidRsaPrivateKey(private_key) {
//return crypto.verifyPrivateKey(private_key, "pem", "rsa"); // true/false
return private_key.includes("PRIVATE KEY");
}
}
Loading

0 comments on commit 34b49a1

Please sign in to comment.