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
text/typescript
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;