Loading
Create a secure command-line password manager with AES-256 encryption, password generation, and clipboard integration.
Password managers are essential security tools, and building one from scratch teaches you the fundamentals of symmetric encryption, key derivation, and secure data handling. In this tutorial, you will build a fully functional CLI password manager in TypeScript that encrypts your vault with AES-256-GCM, derives keys from a master password using PBKDF2, generates strong passwords, copies credentials to your clipboard, and supports searching across entries.
Everything runs locally on your machine. No cloud services, no third-party APIs. You will use only Node.js built-in modules for cryptography, making this project cross-platform across macOS, Windows, and Linux without any native dependencies.
By the end, you will have a tool you can actually use daily, and a deep understanding of how encryption protects data at rest.
Initialize the project and configure TypeScript for strict CLI development.
Update package.json to add build and run scripts:
Create src/types.ts with the core data structures:
The master password must never be stored. Instead, derive an encryption key from it using PBKDF2 with a random salt.
Create src/crypto.ts:
The iteration count of 600,000 follows OWASP 2024 recommendations for PBKDF2-SHA512. This makes brute-force attacks computationally expensive while keeping unlock time under two seconds on modern hardware.
Add encrypt and decrypt functions to src/crypto.ts:
GCM mode provides both confidentiality and integrity. The authentication tag ensures that any tampering with the encrypted data is detected during decryption.
Create src/vault.ts to handle reading and writing the encrypted vault file:
File permissions are set to 0o700 for the directory and 0o600 for the vault file, preventing other users on the system from reading your passwords.
Create src/generator.ts with a cryptographically secure password generator:
Using crypto.randomInt instead of Math.random ensures uniform distribution without modulo bias, critical for security-sensitive applications.
Create src/clipboard.ts with cross-platform clipboard support:
Create src/input.ts to handle secure password input and menu prompts:
The promptSecret function reads input character-by-character in raw mode, preventing the password from being displayed on screen or saved in shell history.
Create src/commands.ts with all the core operations:
Create src/index.ts to wire the CLI together:
Build and test the complete application:
Walk through the full workflow: create a vault, add three entries (one with a generated password), list them, search by name, copy a password to clipboard, and delete an entry. Verify the vault file at ~/.pwmanager/vault.enc is unreadable without the master password.
To harden the application for real-world use, add these improvements:
Auto-lock timeout in src/commands.ts:
Password strength validation in src/generator.ts:
Export to encrypted backup:
You now have a working password manager with AES-256-GCM encryption, PBKDF2 key derivation, secure password generation, cross-platform clipboard integration, and search. The vault is a single encrypted file you can back up anywhere. No cloud, no accounts, no trust required beyond your master password.
mkdir password-manager && cd password-manager
npm init -y
npm install -D typescript @types/node
npx tsc --init --strict --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir dist --rootDir src
mkdir src{
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc && node dist/index.js"
}
}export interface VaultEntry {
id: string;
name: string;
username: string;
password: string;
url?: string;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface Vault {
version: number;
entries: VaultEntry[];
}
export interface EncryptedVault {
version: number;
salt: string;
iv: string;
tag: string;
data: string;
}import { randomBytes, pbkdf2Sync, createCipheriv, createDecipheriv } from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const KEY_LENGTH = 32;
const IV_LENGTH = 16;
const SALT_LENGTH = 32;
const PBKDF2_ITERATIONS = 600_000;
const DIGEST = "sha512";
export function deriveKey(password: string, salt: Buffer): Buffer {
return pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, DIGEST);
}
export function generateSalt(): Buffer {
return randomBytes(SALT_LENGTH);
}
export function generateIV(): Buffer {
return randomBytes(IV_LENGTH);
}export function encrypt(plaintext: string, key: Buffer): { iv: string; tag: string; data: string } {
const iv = generateIV();
const cipher = createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const tag = cipher.getAuthTag();
return {
iv: iv.toString("hex"),
tag: tag.toString("hex"),
data: encrypted,
};
}
export function decrypt(encrypted: string, key: Buffer, iv: string, tag: string): string {
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, "hex"));
decipher.setAuthTag(Buffer.from(tag, "hex"));
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { deriveKey, generateSalt, encrypt, decrypt } from "./crypto.js";
import type { Vault, EncryptedVault } from "./types.js";
const VAULT_PATH = join(homedir(), ".pwmanager", "vault.enc");
const VAULT_DIR = join(homedir(), ".pwmanager");
function ensureVaultDir(): void {
const { mkdirSync } = require("node:fs");
if (!existsSync(VAULT_DIR)) {
mkdirSync(VAULT_DIR, { recursive: true, mode: 0o700 });
}
}
export function saveVault(vault: Vault, masterPassword: string): void {
ensureVaultDir();
const salt = generateSalt();
const key = deriveKey(masterPassword, salt);
const plaintext = JSON.stringify(vault);
const { iv, tag, data } = encrypt(plaintext, key);
const encryptedVault: EncryptedVault = {
version: vault.version,
salt: salt.toString("hex"),
iv,
tag,
data,
};
writeFileSync(VAULT_PATH, JSON.stringify(encryptedVault), { mode: 0o600 });
}
export function loadVault(masterPassword: string): Vault {
if (!existsSync(VAULT_PATH)) {
return { version: 1, entries: [] };
}
const raw = readFileSync(VAULT_PATH, "utf8");
const encryptedVault: EncryptedVault = JSON.parse(raw);
const salt = Buffer.from(encryptedVault.salt, "hex");
const key = deriveKey(masterPassword, salt);
try {
const decrypted = decrypt(encryptedVault.data, key, encryptedVault.iv, encryptedVault.tag);
return JSON.parse(decrypted) as Vault;
} catch {
throw new Error("Invalid master password or corrupted vault.");
}
}
export function vaultExists(): boolean {
return existsSync(VAULT_PATH);
}import { randomInt } from "node:crypto";
const LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const DIGITS = "0123456789";
const SYMBOLS = "!@#$%^&*()-_=+[]{}|;:,.<>?";
interface GeneratorOptions {
length: number;
includeUppercase: boolean;
includeDigits: boolean;
includeSymbols: boolean;
}
export function generatePassword(options: GeneratorOptions): string {
let charset = LOWERCASE;
const required: string[] = [];
if (options.includeUppercase) {
charset += UPPERCASE;
required.push(UPPERCASE[randomInt(UPPERCASE.length)]);
}
if (options.includeDigits) {
charset += DIGITS;
required.push(DIGITS[randomInt(DIGITS.length)]);
}
if (options.includeSymbols) {
charset += SYMBOLS;
required.push(SYMBOLS[randomInt(SYMBOLS.length)]);
}
const remaining = options.length - required.length;
const chars: string[] = [...required];
for (let i = 0; i < remaining; i++) {
chars.push(charset[randomInt(charset.length)]);
}
// Fisher-Yates shuffle using crypto randomInt
for (let i = chars.length - 1; i > 0; i--) {
const j = randomInt(i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join("");
}import { execSync } from "node:child_process";
import { platform } from "node:os";
export function copyToClipboard(text: string): void {
const os = platform();
try {
if (os === "darwin") {
execSync("pbcopy", { input: text, stdio: ["pipe", "ignore", "ignore"] });
} else if (os === "win32") {
execSync("clip", { input: text, stdio: ["pipe", "ignore", "ignore"] });
} else {
// Linux: try xclip first, then xsel, then wl-copy (Wayland)
try {
execSync("xclip -selection clipboard", {
input: text,
stdio: ["pipe", "ignore", "ignore"],
});
} catch {
try {
execSync("xsel --clipboard --input", {
input: text,
stdio: ["pipe", "ignore", "ignore"],
});
} catch {
execSync("wl-copy", { input: text, stdio: ["pipe", "ignore", "ignore"] });
}
}
}
} catch {
throw new Error("Could not copy to clipboard. On Linux, install xclip, xsel, or wl-copy.");
}
}
export function clearClipboardAfter(seconds: number): void {
setTimeout(() => {
try {
copyToClipboard("");
} catch {
// Clipboard clearing is best-effort
}
}, seconds * 1000);
}import { createInterface } from "node:readline";
export function prompt(question: string): Promise<string> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
export function promptSecret(question: string): Promise<string> {
return new Promise((resolve) => {
process.stdout.write(question);
const stdin = process.stdin;
const wasRaw = stdin.isRaw;
if (stdin.isTTY) {
stdin.setRawMode(true);
}
stdin.resume();
stdin.setEncoding("utf8");
let input = "";
const onData = (char: string): void => {
if (char === "\n" || char === "\r" || char === "\u0004") {
if (stdin.isTTY) {
stdin.setRawMode(wasRaw ?? false);
}
stdin.pause();
stdin.removeListener("data", onData);
process.stdout.write("\n");
resolve(input);
} else if (char === "\u0003") {
process.exit(0);
} else if (char === "\u007F" || char === "\b") {
input = input.slice(0, -1);
} else {
input += char;
}
};
stdin.on("data", onData);
});
}import { randomUUID } from "node:crypto";
import { loadVault, saveVault } from "./vault.js";
import { generatePassword } from "./generator.js";
import { copyToClipboard, clearClipboardAfter } from "./clipboard.js";
import { prompt, promptSecret } from "./input.js";
import type { Vault, VaultEntry } from "./types.js";
let cachedPassword: string | null = null;
let cachedVault: Vault | null = null;
async function unlock(): Promise<{ vault: Vault; password: string }> {
if (cachedPassword && cachedVault) {
return { vault: cachedVault, password: cachedPassword };
}
const password = await promptSecret("Master password: ");
const vault = loadVault(password);
cachedPassword = password;
cachedVault = vault;
return { vault, password };
}
export async function addEntry(): Promise<void> {
const { vault, password } = await unlock();
const name = await prompt("Entry name: ");
const username = await prompt("Username: ");
const useGenerated = await prompt("Generate password? (y/n): ");
let entryPassword: string;
if (useGenerated.toLowerCase() === "y") {
const length = parseInt((await prompt("Length (default 20): ")) || "20", 10);
entryPassword = generatePassword({
length,
includeUppercase: true,
includeDigits: true,
includeSymbols: true,
});
console.log("Generated password (copied to clipboard).");
copyToClipboard(entryPassword);
clearClipboardAfter(30);
} else {
entryPassword = await promptSecret("Password: ");
}
const url = await prompt("URL (optional): ");
const notes = await prompt("Notes (optional): ");
const entry: VaultEntry = {
id: randomUUID(),
name,
username,
password: entryPassword,
url: url || undefined,
notes: notes || undefined,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
vault.entries.push(entry);
saveVault(vault, password);
cachedVault = vault;
console.log(`Entry "${name}" saved.`);
}
export async function listEntries(): Promise<void> {
const { vault } = await unlock();
if (vault.entries.length === 0) {
console.log("Vault is empty.");
return;
}
vault.entries.forEach((entry, i) => {
console.log(` ${i + 1}. ${entry.name} (${entry.username})`);
});
}
export async function getEntry(): Promise<void> {
const { vault } = await unlock();
const query = await prompt("Search: ");
const matches = vault.entries.filter(
(e) =>
e.name.toLowerCase().includes(query.toLowerCase()) ||
e.username.toLowerCase().includes(query.toLowerCase()) ||
(e.url && e.url.toLowerCase().includes(query.toLowerCase()))
);
if (matches.length === 0) {
console.log("No matches found.");
return;
}
matches.forEach((entry, i) => {
console.log(
` ${i + 1}. ${entry.name} — ${entry.username}${entry.url ? ` — ${entry.url}` : ""}`
);
});
const choice = parseInt(await prompt("Copy password for entry #: "), 10) - 1;
if (choice >= 0 && choice < matches.length) {
copyToClipboard(matches[choice].password);
clearClipboardAfter(30);
console.log(`Password copied. Clipboard clears in 30 seconds.`);
}
}
export async function deleteEntry(): Promise<void> {
const { vault, password } = await unlock();
await listEntries();
const choice = parseInt(await prompt("Delete entry #: "), 10) - 1;
if (choice >= 0 && choice < vault.entries.length) {
const removed = vault.entries.splice(choice, 1)[0];
saveVault(vault, password);
cachedVault = vault;
console.log(`Deleted "${removed.name}".`);
}
}import { vaultExists, saveVault } from "./vault.js";
import { promptSecret, prompt } from "./input.js";
import { addEntry, listEntries, getEntry, deleteEntry } from "./commands.js";
import type { Vault } from "./types.js";
async function initialize(): Promise<void> {
console.log("No vault found. Let's create one.");
const password = await promptSecret("Choose a master password: ");
const confirm = await promptSecret("Confirm master password: ");
if (password !== confirm) {
console.error("Passwords do not match.");
process.exit(1);
}
if (password.length < 12) {
console.error("Master password must be at least 12 characters.");
process.exit(1);
}
const vault: Vault = { version: 1, entries: [] };
saveVault(vault, password);
console.log("Vault created at ~/.pwmanager/vault.enc");
}
async function main(): Promise<void> {
console.log("\n Password Manager CLI\n");
if (!vaultExists()) {
await initialize();
}
let running = true;
while (running) {
console.log("\n 1. Add entry");
console.log(" 2. List entries");
console.log(" 3. Search & copy");
console.log(" 4. Delete entry");
console.log(" 5. Quit\n");
const choice = await prompt("Choose: ");
switch (choice) {
case "1":
await addEntry();
break;
case "2":
await listEntries();
break;
case "3":
await getEntry();
break;
case "4":
await deleteEntry();
break;
case "5":
running = false;
console.log("Vault locked. Goodbye.");
break;
default:
console.log("Invalid option.");
}
}
}
main().catch((error: unknown) => {
console.error("Fatal error:", error instanceof Error ? error.message : error);
process.exit(1);
});npm run build
npm startconst LOCK_TIMEOUT_MS = 5 * 60 * 1000;
let lastActivity = Date.now();
function checkLock(): void {
if (Date.now() - lastActivity > LOCK_TIMEOUT_MS) {
cachedPassword = null;
cachedVault = null;
console.log("\nSession timed out. Re-enter master password.");
}
lastActivity = Date.now();
}export function assessStrength(password: string): "weak" | "fair" | "strong" {
let score = 0;
if (password.length >= 12) score++;
if (password.length >= 20) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
if (score <= 2) return "weak";
if (score <= 3) return "fair";
return "strong";
}import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
export function exportBackup(destination: string): void {
const vaultPath = join(homedir(), ".pwmanager", "vault.enc");
const data = readFileSync(vaultPath);
const backupName = `vault-backup-${Date.now()}.enc`;
writeFileSync(join(destination, backupName), data, { mode: 0o600 });
console.log(`Backup saved: ${backupName}`);
}