UNPKG

jwt-token-pair-generator

Version:

A secure RSA token pair generator CLI and library for generating and storing RSA keys for JWT

301 lines (264 loc) 11.9 kB
import fs from "fs"; import crypto from "crypto"; import path from "path"; import dotenv from "dotenv"; import { logger, type CustomLogger } from "./logger"; import { SecureKeyGeneratorConfig, KeyPair, KeyGenerationOptions, TokenKeyPairs } from "./interface"; dotenv.config(); /** * SecureKeyGenerator generates RSA key pairs for tokens and stores them securely. */ export class SecureKeyGenerator { private readonly SECURE_FILE_PERMISSIONS: number; private readonly KEY_DIRECTORY?: string | null; private readonly keyPath?: string; private readonly envFileName: string; private readonly modulusLength: number; private readonly logger: CustomLogger; constructor(config: SecureKeyGeneratorConfig = {}) { this.SECURE_FILE_PERMISSIONS = config.filePermissions || 0o644; if (config.keyDirectory) { this.KEY_DIRECTORY = config.keyDirectory || "secure-keys"; this.keyPath = path.join(process.cwd(), this.KEY_DIRECTORY); } this.envFileName = config.envFileName || ".env"; this.modulusLength = config.modulusLength || 2048; const logLevel = config.logLevel; this.logger = logger(logLevel); this.logger.info(`Initialized SecureKeyGenerator with minimal logging. Use '--log all' for detailed logs.`); this.logger.info( `Initialized SecureKeyGenerator with keyDirectory: '${this.KEY_DIRECTORY}', envFileName: '${ this.envFileName }', modulusLength: ${this.modulusLength}, filePermissions: ${this.SECURE_FILE_PERMISSIONS.toString(8)}` ); } /** * Generates an RSA key pair. * @param keyType - Type of key ("access" or "refresh"). * @returns The generated KeyPair. */ private generateRSAKeyPair(keyType: "access" | "refresh"): KeyPair { this.logger.info(`Starting generation of RSA key pair for '${keyType}' token.`); try { const options: KeyGenerationOptions = { modulusLength: this.modulusLength, publicKeyEncoding: { type: "spki", format: "pem", }, privateKeyEncoding: { type: "pkcs8", format: "pem", }, }; this.logger.info( `Generating unencrypted RSA keys for '${keyType}' token with modulusLength: ${this.modulusLength}.` ); const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", options); this.logger.info(`RSA keys generated for '${keyType}' token. Proceeding with validation.`); this.validateKeyPair(publicKey, privateKey); this.logger.info(`RSA key pair for '${keyType}' token validated successfully.`); return { publicKey, privateKey }; } catch (error) { throw new Error(`${keyType} key generation failed: ${error instanceof Error ? error.message : "Unknown error"}`); } } /** * Generates a secure random passphrase. * @returns A hex string representing the passphrase. */ private generateSecurePassphrase(): string { const passphrase = crypto.randomBytes(32).toString("hex"); // Note: Avoid logging the actual passphrase in production logs. this.logger.debug("Generated secure passphrase."); return passphrase; } /** * Validates the generated key pair by signing and verifying a test message. * @param publicKey - The public key. * @param privateKey - The private key. */ private validateKeyPair(publicKey: string, privateKey: string): void { this.logger.info("Validating generated key pair."); try { const testMessage = crypto.randomBytes(32); // Sign the test message using the unencrypted private key const signature = crypto.sign("sha256", testMessage, privateKey); // Verify the signature using the public key const isValid = crypto.verify("sha256", testMessage, publicKey, signature); if (!isValid) { throw new Error("Key pair validation failed"); } this.logger.info("Key pair validation succeeded."); } catch (error) { throw new Error(`Key validation failed: ${error instanceof Error ? error.message : "Unknown error"}`); } } /** * Creates the directory for storing keys with secure permissions. */ private async createSecureKeyDirectory(): Promise<void> { this.logger.info(`Ensuring key directory exists at '${this.keyPath}'.`); try { if (!this.keyPath) return; if (!fs.existsSync(this.keyPath)) { this.logger.info(`Key directory does not exist. Creating directory at '${this.keyPath}'.`); await fs.promises.mkdir(this.keyPath, { recursive: true }); this.logger.info(`Key directory created at '${this.keyPath}'.`); } else { this.logger.info(`Key directory already exists at '${this.keyPath}'.`); } try { await fs.promises.chmod(this.keyPath, 0o700); this.logger.info(`Set restrictive permissions (700) on key directory '${this.keyPath}'.`); } catch (error) { this.logger.warn("Could not set restrictive directory permissions. Using default permissions."); } } catch (error) { throw new Error(`Failed to create secure directory: ${error instanceof Error ? error.message : "Unknown error"}`); } } /** * Loads existing environment variables from the configured env file. * @returns An object containing key/value pairs from the env file. */ private async loadExistingEnv(): Promise<Record<string, string>> { this.logger.info(`Loading existing environment variables from '${this.envFileName}'.`); try { if (fs.existsSync(this.envFileName)) { const envContent = await fs.promises.readFile(this.envFileName, "utf8"); const env: Record<string, string> = {}; envContent.split("\n").forEach((line) => { const trimmedLine = line.trim(); if (trimmedLine && !trimmedLine.startsWith("#")) { const [key, ...valueParts] = trimmedLine.split("="); if (key) { env[key.trim()] = valueParts.join("=").replace(/^"|"$/g, "").trim(); } } }); this.logger.info(`Loaded existing environment variables from '${this.envFileName}'.`); return env; } this.logger.info(`No existing env file found at '${this.envFileName}'. Starting fresh.`); return {}; } catch (error) { this.logger.warn(`Could not read existing env file: ${error instanceof Error ? error.message : "Unknown error"}`); return {}; } } /** * Writes a file with the specified permissions, with fallback on failure. * @param filePath - The path to the file. * @param content - The file content. * @param mode - The file permission mode. */ private async writeFileWithFallback(filePath: string, content: string, mode: number): Promise<void> { this.logger.info(`Writing file '${filePath}' with permissions ${mode.toString(8)}.`); try { await fs.promises.writeFile(filePath, content, { mode }); this.logger.info(`Successfully wrote file '${filePath}' with restricted permissions.`); } catch (error) { this.logger.warn( `Could not write file with restricted permissions (${mode.toString(8)}). Trying with default permissions.` ); await fs.promises.writeFile(filePath, content); this.logger.info(`Successfully wrote file '${filePath}' with default permissions.`); } } /** * Saves the generated token keys to disk and updates the environment file. * @param tokenKeys - The generated token key pairs. */ private async saveKeys(tokenKeys: TokenKeyPairs): Promise<void> { this.logger.info("Starting to save generated token keys."); try { let newEnv = {}; const existingEnv = await this.loadExistingEnv(); if (this.keyPath) { await this.createSecureKeyDirectory(); // Define file paths for keys. const accessPublicPath = path.join(this.keyPath, "access-public.pem"); const accessPrivatePath = path.join(this.keyPath, "access-private.pem"); const refreshPublicPath = path.join(this.keyPath, "refresh-public.pem"); const refreshPrivatePath = path.join(this.keyPath, "refresh-private.pem"); // Save key files. this.logger.info("Saving access token public key."); await this.writeFileWithFallback(accessPublicPath, tokenKeys.access.publicKey, this.SECURE_FILE_PERMISSIONS); this.logger.info("Saving access token private key."); await this.writeFileWithFallback(accessPrivatePath, tokenKeys.access.privateKey, this.SECURE_FILE_PERMISSIONS); this.logger.info("Saving refresh token public key."); await this.writeFileWithFallback(refreshPublicPath, tokenKeys.refresh.publicKey, this.SECURE_FILE_PERMISSIONS); this.logger.info("Saving refresh token private key."); await this.writeFileWithFallback( refreshPrivatePath, tokenKeys.refresh.privateKey, this.SECURE_FILE_PERMISSIONS ); // Load existing env variables. this.logger.info(`Loading existing environment variables from '${this.envFileName}'.`); newEnv = { ...existingEnv, ACCESS_TOKEN_PUBLIC_KEY_PATH: accessPublicPath, ACCESS_TOKEN_PRIVATE_KEY_PATH: accessPrivatePath, REFRESH_TOKEN_PUBLIC_KEY_PATH: refreshPublicPath, REFRESH_TOKEN_PRIVATE_KEY_PATH: refreshPrivatePath, }; } // Merge new key-related variables. newEnv = { ...existingEnv, ACCESS_TOKEN_PUBLIC_KEY: tokenKeys.access.publicKey, ACCESS_TOKEN_PRIVATE_KEY: tokenKeys.access.privateKey, REFRESH_TOKEN_PUBLIC_KEY: tokenKeys.refresh.publicKey, REFRESH_TOKEN_PRIVATE_KEY: tokenKeys.refresh.privateKey, }; this.logger.info("Converting key variables to env file format."); const envContent = Object.entries(newEnv) .map(([key, value]) => `${key}="${(value as string).replace(/\n/g, "\\n")}"`) .join("\n"); this.logger.step(`Writing updated environment variables to '${this.envFileName}'.`); await this.writeFileWithFallback(this.envFileName, envContent, this.SECURE_FILE_PERMISSIONS); this.logger.info("All keys have been generated and saved successfully."); this.printSecurityInstructions(); } catch (error) { throw new Error(`Failed to save keys: ${error instanceof Error ? error.message : "Unknown error"}`); } } /** * Prints security instructions to the console. */ private printSecurityInstructions(): void { this.logger.step(` Security Notes: 1. Key files have been generated in '${this.keyPath}' 2. Both file paths and actual keys are stored in '${this.envFileName}' 3. Access and Refresh token key pairs have been generated. 4. Ensure your .gitignore includes: - ${this.envFileName} - ${this.KEY_DIRECTORY}/ 5. Consider moving keys to a secure key management service for production. 6. Backup these keys securely and never commit them to version control. 7. Previous environment variables have been preserved. Note: If you see any permission warnings, consider manually restricting file permissions using: chmod 600 ${this.keyPath}/*.pem `); } /** * Public method to generate RSA token key pairs. */ public async generate(): Promise<void> { this.logger.step("Starting RSA token key pair generation process."); try { const tokenKeys: TokenKeyPairs = { access: this.generateRSAKeyPair("access"), refresh: this.generateRSAKeyPair("refresh"), }; await this.saveKeys(tokenKeys); this.logger.step("RSA token key pair generation process completed successfully."); } catch (error) { this.logger.error(`Error during key generation: ${error instanceof Error ? error.message : "Unknown error"}`); process.exit(1); } } } export default SecureKeyGenerator;