Skip to content

Commit 34b49a1

Browse files
committed
[DRAFT] REFACTOR: encrypted key support for ssv modal
1 parent c531dc2 commit 34b49a1

File tree

11 files changed

+1608
-190
lines changed

11 files changed

+1608
-190
lines changed

launcher/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "stereum-launcher",
3-
"version": "2.0.4",
3+
"version": "2.1.0",
44
"private": true,
55
"description": "Stereum Ethereum Node Setup Launcher",
66
"author": "stereum.net",
@@ -45,6 +45,7 @@
4545
"leader-line-new": "^1.1.9",
4646
"pinia": "^2.0.33",
4747
"qrcode": "^1.5.1",
48+
"semver": "^7.6.0",
4849
"ssh2": "^1.1.0",
4950
"swiper": "^11.0.6",
5051
"vue": "^3.2.33",
@@ -92,4 +93,4 @@
9293
"type": "git",
9394
"url": "[email protected]:stereum-dev/ethereum-node.git"
9495
}
95-
}
96+
}

launcher/src/backend/NodeConnection.js

Lines changed: 714 additions & 44 deletions
Large diffs are not rendered by default.

launcher/src/backend/SSHService.js

Lines changed: 59 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ export class SSHService {
2121
}, 100);
2222
}
2323

24-
static checkExecError(err) {
24+
static checkExecError(err, accept_empty_result = false) {
25+
if (accept_empty_result) return err.rc != 0;
2526
return !err || err.rc != 0;
2627
}
2728

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

65-
if (this.connectionInfo && !this.addingConnection && (this.connectionPool.length < 6 || this.connectionPool[threshholdIndex]?._chanMgr?._count > 0)) {
66-
await this.connect(this.connectionInfo)
66+
if (
67+
this.connectionInfo &&
68+
!this.addingConnection &&
69+
(this.connectionPool.length < 6 || this.connectionPool[threshholdIndex]?._chanMgr?._count > 0)
70+
) {
71+
await this.connect(this.connectionInfo);
6772
}
6873
if (this.connectionPool.length > 5 && this.connectionPool[threshholdIndex]?._chanMgr?._count === 0) {
6974
this.removeConnectionCount++;
@@ -302,12 +307,12 @@ export class SSHService {
302307
const keyPair = generateKeyPairSync(opts.keyType, opts);
303308
let exitingKeys = await this.readSSHKeyFile();
304309
if (keyPair.public) {
305-
let allKeys = [...exitingKeys, keyPair.public]
306-
await this.writeSSHKeyFile(allKeys)
307-
const savePath = path.join(opts.pickPath, opts.keyType.toLowerCase())
308-
await fs.promises.writeFile(savePath, keyPair.private)
309-
await fs.promises.writeFile(savePath + ".pub", keyPair.public)
310-
return allKeys
310+
let allKeys = [...exitingKeys, keyPair.public];
311+
await this.writeSSHKeyFile(allKeys);
312+
const savePath = path.join(opts.pickPath, opts.keyType.toLowerCase());
313+
await fs.promises.writeFile(savePath, keyPair.private);
314+
await fs.promises.writeFile(savePath + ".pub", keyPair.public);
315+
return allKeys;
311316
}
312317
return exitingKeys;
313318
} catch (err) {
@@ -316,7 +321,7 @@ export class SSHService {
316321
}
317322

318323
async readSSHKeyFile(sshDirPath = `~/.ssh`) {
319-
let authorizedKeys = []
324+
let authorizedKeys = [];
320325
try {
321326
if (sshDirPath.endsWith("/")) sshDirPath = sshDirPath.slice(0, -1, ""); //if path ends with '/' remove it
322327
let result = await this.exec(`cat ${sshDirPath}/authorized_keys`);
@@ -335,8 +340,10 @@ export class SSHService {
335340
async writeSSHKeyFile(keys = [], sshDirPath = `~/.ssh`) {
336341
try {
337342
if (sshDirPath.endsWith("/")) sshDirPath = sshDirPath.slice(0, -1, ""); //if path ends with '/' remove it
338-
let newKeys = keys.join("\n")
339-
let result = await this.exec(`echo -e ${StringUtils.escapeStringForShell(newKeys)} > ${sshDirPath}/authorized_keys`);
343+
let newKeys = keys.join("\n");
344+
let result = await this.exec(
345+
`echo -e ${StringUtils.escapeStringForShell(newKeys)} > ${sshDirPath}/authorized_keys`
346+
);
340347
if (SSHService.checkExecError(result)) {
341348
throw new Error("Failed writing authorized keys:\n" + SSHService.extractExecError(result));
342349
}
@@ -351,7 +358,7 @@ export class SSHService {
351358
* Checks if mode is a directory with fs constants https://nodejs.org/api/fs.html#fsconstants.
352359
* `S_IFMT` is a bit mask used to extract the file type code.
353360
* `S_IFDIR` is a file type constant for a directory.
354-
* @param {Integer} mode
361+
* @param {Integer} mode
355362
* @returns `True` if mode is a directory, `False` otherwise
356363
*/
357364
isDir(mode) {
@@ -361,11 +368,11 @@ export class SSHService {
361368
/**
362369
* Get an SFTP session object from the connection pool.
363370
* Optionally takes a ssh session object as an argument, otherwise it will get a new connection from the pool.
364-
* @param {Client} [conn]
371+
* @param {Client} [conn]
365372
* @returns sftp session object
366373
*/
367374
async getSFTPSession(conn = null) {
368-
conn = this.getConnectionFromPool()
375+
conn = this.getConnectionFromPool();
369376
return new Promise((resolve, reject) => {
370377
conn.sftp((err, sftp) => {
371378
if (err) {
@@ -379,8 +386,8 @@ export class SSHService {
379386

380387
/**
381388
* Reads a directory's contents from remotePath using SFTP
382-
* @param {String} remotePath
383-
* @param {SFTP Session} [sftp]
389+
* @param {String} remotePath
390+
* @param {SFTP Session} [sftp]
384391
* @returns Array of objects containing filename and mode of the contents of a given directory on the remote server.
385392
*/
386393
async readDirectorySFTP(remotePath, sftp = null) {
@@ -401,23 +408,23 @@ export class SSHService {
401408
/**
402409
* Returns an array of objects containing filename and mode of the contents of a given directory on the remote server.
403410
* Workaround for readdir not running with sudo permissions.
404-
* @param {String} remotePath
405-
* @returns
411+
* @param {String} remotePath
412+
* @returns
406413
*/
407414
async readDirectorySSH(remotePath) {
408415
try {
409416
const result = await this.exec(`find ${remotePath} -maxdepth 1 -exec stat --format '%n\n%f\n' {} +`);
410417
if (SSHService.checkExecError(result)) {
411418
throw new Error("Failed reading directory: " + SSHService.extractExecError(result));
412419
}
413-
let files = result.stdout.split("\n\n").filter((e) => e)
420+
let files = result.stdout.split("\n\n").filter((e) => e);
414421
files.shift(); //remove the first element which is the directory itself
415422
return files.map((file) => {
416423
let [filename, mode] = file.split("\n");
417424
filename = path.posix.basename(path.posix.normalize(filename)); // normalize path
418425
mode = parseInt(mode, 16); // convert mode from hex to integer
419426
return { filename, mode };
420-
})
427+
});
421428
} catch (error) {
422429
log.error("Failed reading directory via SSH: ", error);
423430
return [];
@@ -426,14 +433,14 @@ export class SSHService {
426433

427434
/**
428435
* Reads a directory's contents from localPath
429-
* @param {String} localPath
436+
* @param {String} localPath
430437
* @returns Array of Dirent objects or an empty array on error
431438
*/
432439
async readDirectoryLocal(localPath) {
433440
try {
434-
log.info("localPath", localPath)
441+
log.info("localPath", localPath);
435442
const filenames = await fs.promises.readdir(localPath, { withFileTypes: true });
436-
log.info("filenames", filenames)
443+
log.info("filenames", filenames);
437444
return filenames;
438445
} catch (error) {
439446
console.error("Failed reading local directory: ", error);
@@ -445,30 +452,30 @@ export class SSHService {
445452
* Downloads a file from remotePath to localPath.
446453
* Uses "cat" to get file contents and pipes that stream to a local write stream.
447454
* This is a workaround for the lack of sudo permissions with sftp createReadStream.
448-
* @param {String} remotePath
449-
* @param {String} localPath
450-
* @param {Client} [conn]
455+
* @param {String} remotePath
456+
* @param {String} localPath
457+
* @param {Client} [conn]
451458
* @returns `void`
452459
*/
453460
async downloadFileSSH(remotePath, localPath, conn = this.getConnectionFromPool()) {
454461
return new Promise((resolve, reject) => {
455462
const writeStream = fs.createWriteStream(localPath);
456-
writeStream.on('error', reject);
457-
writeStream.on('close', resolve);
463+
writeStream.on("error", reject);
464+
writeStream.on("close", resolve);
458465

459466
conn.exec(`sudo cat ${remotePath}`, function (err, stream) {
460467
if (err) throw err;
461-
stream.on('error', reject);
468+
stream.on("error", reject);
462469
stream.pipe(writeStream);
463470
});
464471
});
465472
}
466473

467474
/**
468475
* Downloads a Directory and all its contents recursively from the remotePath to the localPath
469-
* @param {String} remotePath
470-
* @param {String} localPath
471-
* @param {Client} [conn]
476+
* @param {String} remotePath
477+
* @param {String} localPath
478+
* @param {Client} [conn]
472479
* @returns `true` if download was successful, `false` otherwise
473480
*/
474481
async downloadDirectorySSH(remotePath, localPath, conn = null) {
@@ -481,7 +488,7 @@ export class SSHService {
481488
fs.mkdirSync(localPath, { recursive: true });
482489
}
483490

484-
const dirContents = await this.readDirectorySSH(remotePath)
491+
const dirContents = await this.readDirectorySSH(remotePath);
485492
for (let item of dirContents) {
486493
const remoteFilePath = path.posix.join(remotePath, item.filename);
487494
const localFilePath = path.join(localPath, item.filename);
@@ -492,37 +499,37 @@ export class SSHService {
492499
await this.downloadFileSSH(remoteFilePath, localFilePath);
493500
}
494501
}
495-
return true
502+
return true;
496503
} catch (error) {
497504
log.error("Failed to download directory via SSH: ", error);
498-
return false
505+
return false;
499506
}
500507
}
501508
/**
502509
* Uploads a file from localPath to remotePath
503-
* @param {String} localPath
504-
* @param {String} remotePath
505-
* @param {Client} [conn]
510+
* @param {String} localPath
511+
* @param {String} remotePath
512+
* @param {Client} [conn]
506513
* @returns `void`
507514
*/
508515
async uploadFileSSH(localPath, remotePath, conn = this.getConnectionFromPool()) {
509516
return new Promise((resolve, reject) => {
510517
const readStream = fs.createReadStream(localPath);
511-
readStream.on('error', reject);
512-
readStream.on('close', resolve);
518+
readStream.on("error", reject);
519+
readStream.on("close", resolve);
513520

514521
conn.exec(`sudo cat > ${remotePath}`, function (err, stream) {
515522
if (err) throw err;
516-
stream.on('error', reject);
517-
stream.on('close', resolve);
523+
stream.on("error", reject);
524+
stream.on("close", resolve);
518525
readStream.pipe(stream.stdin);
519526
});
520527
});
521528
}
522529
/**
523530
* Ensures that the remotePath exists and is owned by the current user
524-
* @param {String} remotePath
525-
* @param {Client} [conn]
531+
* @param {String} remotePath
532+
* @param {Client} [conn]
526533
*/
527534
async ensureRemotePathExists(remotePath, conn = this.getConnectionFromPool()) {
528535
return new Promise((resolve, reject) => {
@@ -535,9 +542,9 @@ export class SSHService {
535542

536543
/**
537544
* Uploads a directory and all its contents recursively from the localPath to the remotePath
538-
* @param {String} localPath
539-
* @param {String} remotePath
540-
* @param {Client} [conn]
545+
* @param {String} localPath
546+
* @param {String} remotePath
547+
* @param {Client} [conn]
541548
* @returns `true` if upload was successful, `false` otherwise
542549
*/
543550
async uploadDirectorySSH(localPath, remotePath, conn = null) {
@@ -546,9 +553,9 @@ export class SSHService {
546553
conn = await this.getConnectionFromPool();
547554
}
548555

549-
await this.ensureRemotePathExists(remotePath)
556+
await this.ensureRemotePathExists(remotePath);
550557

551-
const dirContents = await this.readDirectoryLocal(localPath)
558+
const dirContents = await this.readDirectoryLocal(localPath);
552559
for (let item of dirContents) {
553560
const remoteFilePath = path.posix.join(remotePath, item.name);
554561
const localFilePath = path.join(localPath, item.name);
@@ -558,10 +565,10 @@ export class SSHService {
558565
await this.uploadFileSSH(localFilePath, remoteFilePath);
559566
}
560567
}
561-
return true
568+
return true;
562569
} catch (error) {
563570
log.error("Failed to upload directory via SSH: ", error);
564-
return false
571+
return false;
565572
}
566573
}
567574
}

launcher/src/backend/StringUtils.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const log = require("electron-log");
22
import * as crypto from "crypto";
3+
import { Base64 } from "@/share/Base64";
34

45
export class StringUtils {
56
static escapeStringForShell(shellCmd) {
@@ -27,4 +28,83 @@ export class StringUtils {
2728
}
2829
return result;
2930
}
31+
32+
/**
33+
* Returns true if str is base64 encoded
34+
*/
35+
static isBase64(str, enc = "utf8") {
36+
return Base64.validate(str, enc);
37+
}
38+
39+
/**
40+
* Decode base64 encoded string
41+
*/
42+
static base64decode(str, enc = "utf8") {
43+
return Base64.decode(str, enc);
44+
}
45+
46+
/**
47+
* Encode string to base64
48+
*/
49+
static base64encode(str, enc = "utf8") {
50+
return Base64.encode(str, enc);
51+
}
52+
53+
/**
54+
* Get SSV public key from secret key
55+
*/
56+
static getSSVPublicKeyFromSecretKey(secret_key, raw = false, enc = "utf8") {
57+
// Decode the base64 encoded SSV secret_key and get the RSA key in PEM format
58+
const rsa_public_key = this.getRsaPublicKeyFromPrivateKey(secret_key, enc);
59+
60+
// RSA keys in PEM format that are generated by SSV are usually starting/ending
61+
// with 'START RSA PUBLIC KEY' / ' END RSA PUBLIC KEY' while RSA keys generated
62+
// via crypto lib start/end with 'START PUBLIC KEY' / ' END PUBLIC KEY'.
63+
// Technically it does not matter but it is better to get the exact same base64
64+
// encoded result as the user would get from SSV (to avoid confusion)
65+
const rsa_public_key_ssv_style = rsa_public_key.replace(/(BEGIN|END) (PUBLIC.*)/g, "$1 RSA $2");
66+
67+
// Return the Base64 econded (or raw) RSA key
68+
return !raw ? this.base64encode(rsa_public_key_ssv_style) : rsa_public_key_ssv_style;
69+
}
70+
71+
/**
72+
* Get RSA public key from private key
73+
*/
74+
static getRsaPublicKeyFromPrivateKey(private_key) {
75+
try {
76+
if (this.isBase64(private_key)) private_key = this.base64decode(private_key);
77+
78+
const pubKeyObject = crypto.createPublicKey({
79+
key: private_key,
80+
format: "pem",
81+
});
82+
83+
const publicKey = pubKeyObject.export({
84+
format: "pem",
85+
type: "spki",
86+
});
87+
88+
return publicKey;
89+
} catch (err) {
90+
log.error("Can't create RSA public key from RSA private key ", err);
91+
throw new Error("Can't create RSA public key from RSA private key : " + err);
92+
}
93+
}
94+
95+
/**
96+
* Return true if given public_key is a valid RSA public key in PEM format
97+
*/
98+
static isValidRsaPublicKey(public_key) {
99+
//eturn crypto.verifyPublicKey(public_key, "pem", "rsa"); // true/false
100+
return public_key.includes("PUBLIC KEY");
101+
}
102+
103+
/**
104+
* Return true if given private_key is a valid RSA private key in PEM format
105+
*/
106+
static isValidRsaPrivateKey(private_key) {
107+
//return crypto.verifyPrivateKey(private_key, "pem", "rsa"); // true/false
108+
return private_key.includes("PRIVATE KEY");
109+
}
30110
}

0 commit comments

Comments
 (0)