Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/validate-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

- name: Install libsecret runtime
run: sudo apt-get update && sudo apt-get install -y libsecret-1-0

- name: Set up Node.js
uses: actions/setup-node@v4
with:
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ Before installing the GenLayer CLI, ensure you have Node.js installed on your sy
npm install -g genlayer
```

### Linux Dependencies

On some Linux distributions with minimal setups (like Debian netinst or Docker images), you may need to manually install libsecret:

```bash
# Ubuntu/Debian
sudo apt-get install libsecret-1-0

# CentOS/RHEL/Fedora
sudo yum install libsecret
# or for newer versions
sudo dnf install libsecret

# Arch Linux
sudo pacman -S libsecret
```

The GenLayer CLI uses the `keytar` library for secure key storage, which relies on `libsecret` on Linux systems.

## Usage

Each command includes syntax, usage information, and examples to help you effectively use the CLI for interacting with the GenLayer environment.
Expand Down
2 changes: 1 addition & 1 deletion esbuild.config.dev.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default {
banner: {
js: `const _importMetaUrl = new URL(import.meta.url).pathname;`,
},
external: ["commander", "dockerode", "dotenv", "ethers", "inquirer", "update-check", "ssh2", "fs-extra", "esbuild"]
external: ["commander", "dockerode", "dotenv", "ethers", "inquirer", "update-check", "ssh2", "fs-extra", "esbuild", "keytar"]
},
watch: true,
};
2 changes: 1 addition & 1 deletion esbuild.config.prod.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export default {
banner: {
js: `const _importMetaUrl = new URL(import.meta.url).pathname;`,
},
external: ["commander", "dockerode", "dotenv", "ethers", "inquirer", "update-check", "ssh2", "fs-extra", "esbuild"]
external: ["commander", "dockerode", "dotenv", "ethers", "inquirer", "update-check", "ssh2", "fs-extra", "esbuild", "keytar"]
},
watch: false,
};
1,065 changes: 543 additions & 522 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"fs-extra": "^11.3.0",
"genlayer-js": "^0.11.0",
"inquirer": "^12.0.0",
"keytar": "^7.9.0",
"node-fetch": "^3.0.0",
"open": "^10.1.0",
"ora": "^8.2.0",
Expand Down
18 changes: 18 additions & 0 deletions src/commands/keygen/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Command } from "commander";
import { CreateKeypairOptions, KeypairCreator } from "./create";
import { UnlockAction } from "./unlock";
import { LockAction } from "./lock";

export function initializeKeygenCommands(program: Command) {

Expand All @@ -17,5 +19,21 @@ export function initializeKeygenCommands(program: Command) {
await keypairCreator.createKeypairAction(options);
});

keygenCommand
.command("unlock")
.description("Unlock your wallet by storing the decrypted private key in OS keychain")
.action(async () => {
const unlockAction = new UnlockAction();
await unlockAction.execute();
});

keygenCommand
.command("lock")
.description("Lock your wallet by removing the decrypted private key from OS keychain")
.action(async () => {
const lockAction = new LockAction();
await lockAction.execute();
});

return program;
}
31 changes: 31 additions & 0 deletions src/commands/keygen/lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { BaseAction } from "../../lib/actions/BaseAction";

export class LockAction extends BaseAction {
async execute(): Promise<void> {
this.startSpinner("Checking keychain availability...");

const keychainAvailable = await this.keychainManager.isKeychainAvailable();
if (!keychainAvailable) {
this.failSpinner("OS keychain is not available. This command requires a supported keychain (e.g. macOS Keychain, Windows Credential Manager, or GNOME Keyring).");
return;
}

this.setSpinnerText("Checking for cached private key...");

const hasCachedKey = await this.keychainManager.getPrivateKey();
if (!hasCachedKey) {
this.succeedSpinner("Wallet is already locked (no cached key found in OS keychain).");
return;
}

this.setSpinnerText("Removing private key from OS keychain...");

try {
await this.keychainManager.removePrivateKey();

this.succeedSpinner("Wallet locked successfully! Your private key has been removed from the OS keychain.");
} catch (error) {
this.failSpinner("Failed to lock wallet.", error);
}
}
}
41 changes: 41 additions & 0 deletions src/commands/keygen/unlock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { BaseAction } from "../../lib/actions/BaseAction";
import { readFileSync, existsSync } from "fs";
import { ethers } from "ethers";

export class UnlockAction extends BaseAction {
async execute(): Promise<void> {
this.startSpinner("Checking keychain availability...");

const keychainAvailable = await this.keychainManager.isKeychainAvailable();
if (!keychainAvailable) {
this.failSpinner("OS keychain is not available. This command requires a supported keychain (e.g. macOS Keychain, Windows Credential Manager, or GNOME Keyring).");
return;
}

this.setSpinnerText("Checking for existing keystore...");

const keypairPath = this.getConfigByKey("keyPairPath");
if (!keypairPath || !existsSync(keypairPath)) {
this.failSpinner("No keystore file found. Please create a keypair first using 'genlayer keygen create'.");
return;
}

const keystoreData = JSON.parse(readFileSync(keypairPath, "utf-8"));
if (!this.isValidKeystoreFormat(keystoreData)) {
this.failSpinner("Invalid keystore format. Expected encrypted keystore file.");
return;
}

this.stopSpinner();

try {
const password = await this.promptPassword("Enter password to decrypt keystore:");
const wallet = await ethers.Wallet.fromEncryptedJson(keystoreData.encrypted, password);

await this.keychainManager.storePrivateKey(wallet.privateKey);
this.succeedSpinner("Wallet unlocked successfully! Your private key is now stored securely in the OS keychain.");
} catch (error) {
this.failSpinner("Failed to unlock wallet.", error);
}
}
}
13 changes: 6 additions & 7 deletions src/lib/actions/BaseAction.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {ConfigFileManager} from "../../lib/config/ConfigFileManager";
import {KeychainManager} from "../../lib/config/KeychainManager";
import ora, {Ora} from "ora";
import chalk from "chalk";
import inquirer from "inquirer";
Expand All @@ -14,15 +15,15 @@ export class BaseAction extends ConfigFileManager {
private static readonly DEFAULT_KEYSTORE_PATH = "./keypair.json";
private static readonly MAX_PASSWORD_ATTEMPTS = 3;
private static readonly MIN_PASSWORD_LENGTH = 8;
private static readonly TEMP_KEY_FILENAME = "decrypted_private_key";

private spinner: Ora;
private _genlayerClient: GenLayerClient<GenLayerChain> | null = null;
protected keychainManager: KeychainManager;

constructor() {
super();
this.spinner = ora({text: "", spinner: "dots"});
this.cleanupExpiredTempFiles();
this.keychainManager = new KeychainManager();
}

private async decryptKeystore(keystoreData: KeystoreData, attempt: number = 1): Promise<string> {
Expand All @@ -33,8 +34,6 @@ export class BaseAction extends ConfigFileManager {
const password = await this.promptPassword(message);
const wallet = await ethers.Wallet.fromEncryptedJson(keystoreData.encrypted, password);

this.storeTempFile(BaseAction.TEMP_KEY_FILENAME, wallet.privateKey);

return wallet.privateKey;
} catch (error) {
if (attempt >= BaseAction.MAX_PASSWORD_ATTEMPTS) {
Expand All @@ -45,7 +44,7 @@ export class BaseAction extends ConfigFileManager {
}
}

private isValidKeystoreFormat(data: any): data is KeystoreData {
protected isValidKeystoreFormat(data: any): data is KeystoreData {
return Boolean(
data &&
data.version === 1 &&
Expand Down Expand Up @@ -101,7 +100,7 @@ export class BaseAction extends ConfigFileManager {
}

if (!decryptedPrivateKey) {
const cachedKey = this.getTempFile(BaseAction.TEMP_KEY_FILENAME);
const cachedKey = await this.keychainManager.getPrivateKey();
decryptedPrivateKey = cachedKey ? cachedKey : await this.decryptKeystore(keystoreData);
}
return createAccount(decryptedPrivateKey as Hash);
Expand Down Expand Up @@ -146,7 +145,7 @@ export class BaseAction extends ConfigFileManager {
writeFileSync(finalOutputPath, JSON.stringify(keystoreData, null, 2));
this.writeConfig('keyPairPath', finalOutputPath);

this.clearTempFile(BaseAction.TEMP_KEY_FILENAME);
await this.keychainManager.removePrivateKey();

return wallet.privateKey;
}
Expand Down
78 changes: 0 additions & 78 deletions src/lib/config/ConfigFileManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,15 @@ import path from "path";
import os from "os";
import fs from "fs";

interface TempFileData {
content: string;
timestamp: number;
}

export class ConfigFileManager {
private folderPath: string;
private configFilePath: string;
private tempFolderPath: string;
private static readonly TEMP_FILE_EXPIRATION_MS = 5 * 60 * 1000; // 5 minutes

constructor(baseFolder: string = ".genlayer/", configFileName: string = "genlayer-config.json") {
this.folderPath = path.resolve(os.homedir(), baseFolder);
this.configFilePath = path.resolve(this.folderPath, configFileName);
this.tempFolderPath = path.resolve(os.tmpdir(), "genlayer-temp");
this.ensureFolderExists();
this.ensureConfigFileExists();
this.ensureTempFolderExists();
}

private ensureFolderExists(): void {
Expand All @@ -34,12 +25,6 @@ export class ConfigFileManager {
}
}

private ensureTempFolderExists(): void {
if (!fs.existsSync(this.tempFolderPath)) {
fs.mkdirSync(this.tempFolderPath, { recursive: true, mode: 0o700 }); // Owner-only access
}
}

getFolderPath(): string {
return this.folderPath;
}
Expand All @@ -63,67 +48,4 @@ export class ConfigFileManager {
config[key] = value;
fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
}

storeTempFile(fileName: string, content: string): void {
this.ensureTempFolderExists();
const filePath = path.resolve(this.tempFolderPath, fileName);
const tempData: TempFileData = {
content,
timestamp: Date.now()
};
fs.writeFileSync(filePath, JSON.stringify(tempData), { mode: 0o600 }); // Owner-only access
}

getTempFile(fileName: string): string | null {
const filePath = path.resolve(this.tempFolderPath, fileName);

if (!fs.existsSync(filePath)) {
return null;
}

const fileContent = fs.readFileSync(filePath, "utf-8");
const tempData: TempFileData = JSON.parse(fileContent);

if (Date.now() - tempData.timestamp > ConfigFileManager.TEMP_FILE_EXPIRATION_MS) {
this.clearTempFile(fileName);
return null;
}

return tempData.content;
}

hasTempFile(fileName: string): boolean {
return this.getTempFile(fileName) !== null;
}

clearTempFile(fileName: string): void {
const filePath = path.resolve(this.tempFolderPath, fileName);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}

cleanupExpiredTempFiles(): void {
if (!fs.existsSync(this.tempFolderPath)) {
return;
}

const files = fs.readdirSync(this.tempFolderPath);
const now = Date.now();

for (const file of files) {
const filePath = path.resolve(this.tempFolderPath, file);

try {
const fileContent = fs.readFileSync(filePath, "utf-8");
const tempData: TempFileData = JSON.parse(fileContent);

if (now - tempData.timestamp > ConfigFileManager.TEMP_FILE_EXPIRATION_MS) {
fs.unlinkSync(filePath);
}
} catch (error) {
fs.unlinkSync(filePath);
}
}
}
}
29 changes: 29 additions & 0 deletions src/lib/config/KeychainManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {default as keytar} from 'keytar';

export class KeychainManager {
private static readonly SERVICE = 'genlayer-cli';
private static readonly ACCOUNT = 'default-user';

constructor() {}

async isKeychainAvailable(): Promise<boolean> {
try {
await keytar.findCredentials('test-service');
return true;
} catch {
return false;
}
}
Comment thread
epsjunior marked this conversation as resolved.

async storePrivateKey(privateKey: string): Promise<void> {
return await keytar.setPassword(KeychainManager.SERVICE, KeychainManager.ACCOUNT, privateKey);
}
Comment thread
epsjunior marked this conversation as resolved.

async getPrivateKey(): Promise<string | null> {
return await keytar.getPassword(KeychainManager.SERVICE, KeychainManager.ACCOUNT);
}

async removePrivateKey(): Promise<boolean> {
return await keytar.deletePassword(KeychainManager.SERVICE, KeychainManager.ACCOUNT);
}
}
Loading