raspberry-password-manager
Version:
A simple AES-GCM CLI password manager
336 lines (297 loc) • 12.6 kB
JavaScript
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import os from 'os';
import crypto from 'crypto';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { fileURLToPath } from 'url';
// ───────────────────────────────────────────
//─── MODULE SCOPE & CONFIG ─────────────────
//────────────────────────────────────────────
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// config & storage in user home
const CONFIG_DIR = path.join(os.homedir(), '.passwdm');
if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
const STORE_PATH = path.join(CONFIG_DIR, 'store.json');
let masterKey; // for encryption
// AES-GCM and policy
const IV_LEN = 12;
const LOWER = 'abcdefghijklmnopqrstuvwxyz';
const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const DIGITS = '0123456789';
const SPECIAL = '!@#$^&*~';
// ───────────────────────────────────────────
//─── CONFIG LOADER & INIT SETUP ────────────
//────────────────────────────────────────────
function loadConfig() {
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
}
async function initSetup() {
console.log(chalk.blue('\n🔧 Initializing passwdm configuration…\n'));
const { password, confirm } = await inquirer.prompt([
{
type: 'password',
name: 'password',
message: 'Set application access password:',
mask: '*',
validate: v => v.trim() ? true : 'Password cannot be empty'
},
{
type: 'password',
name: 'confirm',
message: 'Confirm password:',
mask: '*',
validate: (v, answers) => v === answers.password ? true : 'Passwords do not match'
}
]);
const encryptionKey = crypto.randomBytes(32).toString('base64');
const config = { appPassword: password, encryptionKey };
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
console.log(chalk.green('\n✅ Configuration saved to:'), CONFIG_PATH);
console.log(chalk.yellow('\n💾 Please save this ENCRYPTION_KEY somewhere safe:'));
console.log(encryptionKey, '\n');
}
// ───────────────────────────────────────────
//─── INITIAL AUTH & KEY ────────────────────
//────────────────────────────────────────────
async function initAuth() {
const { appPassword } = loadConfig();
const { pwd } = await inquirer.prompt({
type: 'password',
name: 'pwd',
message: 'Enter application access password:'
});
if (pwd !== appPassword) {
console.error(chalk.red('✖ Invalid application password. Exiting.'));
process.exit(1);
}
}
function initEncryptionKey() {
const { encryptionKey } = loadConfig();
const buf = Buffer.from(encryptionKey, 'base64');
if (buf.length !== 32) {
console.error(chalk.red('✖ Invalid key length in config. Please run `passwdm init` again.'));
process.exit(1);
}
masterKey = buf;
}
// ───────────────────────────────────────────
//─── ENCRYPT/DECRYPT UTILITIES ─────────────
//────────────────────────────────────────────
function encryptText(plain) {
const iv = crypto.randomBytes(IV_LEN);
const cipher = crypto.createCipheriv('aes-256-gcm', masterKey, iv);
const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return [iv, ct, tag].map(b => b.toString('hex')).join(':');
}
function decryptText(payload) {
const [ivHex, ctHex, tagHex] = payload.split(':');
const iv = Buffer.from(ivHex, 'hex');
const ct = Buffer.from(ctHex, 'hex');
const tag = Buffer.from(tagHex, 'hex');
const dec = crypto.createDecipheriv('aes-256-gcm', masterKey, iv);
dec.setAuthTag(tag);
return Buffer.concat([dec.update(ct), dec.final()]).toString('utf8');
}
// ───────────────────────────────────────────
//─── STORE I/O ─────────────────────────────
//────────────────────────────────────────────
function loadStore() {
if (!fs.existsSync(STORE_PATH)) {
fs.writeFileSync(STORE_PATH, JSON.stringify({ entries: [] }, null, 2));
}
return JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
}
function saveStore(store) {
fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2));
}
// ───────────────────────────────────────────
//─── PASSWORD GENERATOR & DEPRECATE ────────
//────────────────────────────────────────────
function generatePassword(length = 12) {
if (length < 8) length = 8;
const all = LOWER + UPPER + DIGITS + SPECIAL;
const pick = s => s[Math.floor(Math.random() * s.length)];
const pw = [pick(LOWER), pick(UPPER), pick(DIGITS), pick(SPECIAL)];
while (pw.length < length) pw.push(pick(all));
for (let i = pw.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pw[i], pw[j]] = [pw[j], pw[i]];
}
return pw.join('');
}
function deprecateEntry(e) {
e.deprecated = true;
e.deprecatedAt = new Date().toISOString();
}
// ───────────────────────────────────────────
//─── CLI FLOWS ─────────────────────────────
//────────────────────────────────────────────
async function generateFlow() {
const store = loadStore();
const { name } = await inquirer.prompt({
type: 'input', name: 'name', message: 'App/site name:',
validate: s => s.trim() ? true : 'cannot be empty'
});
const pwd = generatePassword(12);
const enc = encryptText(pwd);
store.entries.push({
id: crypto.randomUUID(),
name: name.trim(),
secret: enc,
createdAt: new Date().toISOString(),
deprecated: false
});
saveStore(store);
console.log(chalk.green(`\n✅ New password for "${name}":\n\n ${chalk.bold(pwd)}\n`));
await pause();
}
async function getFlow() {
const store = loadStore();
const active = store.entries.filter(e => !e.deprecated);
if (!active.length) { console.log(chalk.yellow('No active passwords.')); await pause(); return; }
const { id } = await inquirer.prompt({
type: 'list', name: 'id', message: 'Select entry:',
choices: active.map(e => ({
name: `${e.name} (added ${new Date(e.createdAt).toLocaleString()})`,
value: e.id
}))
});
const entry = store.entries.find(e => e.id === id);
let pwd;
try {
pwd = decryptText(entry.secret);
} catch {
console.log(chalk.red('✖ Decryption failed.'));
const { onError } = await inquirer.prompt({
type: 'list', name: 'onError', message: 'Handle corrupted entry?',
choices: [
{ name: 'Back', value: 'back' },
{ name: 'Delete & new', value: 'regen' }
]
});
if (onError === 'regen') {
const s2 = loadStore();
s2.entries = s2.entries.filter(e => e.id !== entry.id);
const newPwd = generatePassword(12);
const newEnc = encryptText(newPwd);
s2.entries.push({
id: crypto.randomUUID(),
name: entry.name,
secret: newEnc,
createdAt: new Date().toISOString(),
deprecated: false
});
saveStore(s2);
console.log(chalk.green(`\n🔄 Replaced with new password: \n\n ${chalk.bold(newPwd)}\n`));
await pause();
}
return;
}
console.log(chalk.cyan(`\n🔑 Password for "${entry.name}":\n\n ${chalk.bold(pwd)}\n`));
const { next } = await inquirer.prompt({
type: 'list', name: 'next', message: 'Next?',
choices: [
{ name: 'Deprecate+new', value: 'regen' },
{ name: 'Show deprecated', value: 'showOld' },
{ name: 'Back', value: 'back' },
{ name: 'Exit', value: 'exit' }
]
});
if (next === 'regen') {
deprecateEntry(entry);
const newPwd = generatePassword(12);
const newEnc = encryptText(newPwd);
store.entries.push({
id: crypto.randomUUID(),
name: entry.name,
secret: newEnc,
createdAt: new Date().toISOString(),
deprecated: false
});
saveStore(store);
console.log(chalk.green(`\n🔄 New password: \n\n ${chalk.bold(newPwd)}\n`));
await pause();
return;
}
if (next === 'showOld') { await showDeprecated(); return; }
if (next === 'back') { return; }
process.exit(0);
}
async function showDeprecated() {
const store = loadStore();
const old = store.entries.filter(e => e.deprecated);
if (!old.length) { console.log(chalk.yellow('No deprecated.')); await pause(); return; }
const { id } = await inquirer.prompt({
type: 'list', name: 'id', message: 'Select deprecated:',
choices: old.map(e => ({
name: `${e.name} (created ${new Date(e.createdAt).toLocaleString()}, deprecated ${new Date(e.deprecatedAt).toLocaleString()})`,
value: e.id
}))
});
const entry = store.entries.find(e => e.id === id);
const pwd = decryptText(entry.secret);
console.log(chalk.gray(`\n❗ Deprecated for "${entry.name}":\n\n ${chalk.bold(pwd)}\n`));
await pause();
}
async function deleteFlow() {
const store = loadStore();
const names = [...new Set(store.entries.map(e => e.name))];
if (!names.length) { console.log(chalk.yellow('No to delete.')); await pause(); return; }
const { name } = await inquirer.prompt({
type: 'list', name: 'name', message: 'Select site to delete:', choices: names
});
const { confirm } = await inquirer.prompt({
type: 'confirm', name: 'confirm', message: `Delete ALL for "${name}"?`, default: false
});
if (!confirm) return;
const before = store.entries.length;
store.entries = store.entries.filter(e => e.name !== name);
saveStore(store);
console.log(chalk.green(`Deleted ${before - store.entries.length} entries for "${name}".`));
await pause();
}
async function pause() {
await inquirer.prompt([{ type: 'confirm', name: 'ok', message: 'Press ENTER', default: true }]);
}
async function mainMenu() {
console.clear();
console.log(chalk.blue.bold('\n🔐 CLI Password Manager\n'));
const { action } = await inquirer.prompt({
type: 'list', name: 'action', message: 'Choose:',
choices: [
{ name: 'Generate', value: 'gen' },
{ name: 'Get', value: 'get' },
{ name: 'Delete site', value: 'delete' },
{ name: 'Exit', value: 'exit' }
]
});
if (action === 'gen') await generateFlow();
if (action === 'get') await getFlow();
if (action === 'delete') await deleteFlow();
if (action === 'exit') process.exit(0);
await mainMenu();
}
// ───────────────────────────────────────────
//─── STARTUP WRAPPER ───────────────────────
//────────────────────────────────────────────
(async () => {
const args = process.argv.slice(2);
// explicit init
if (args[0] === 'init') {
await initSetup();
process.exit(0);
}
// auto-init if no config found
if (!fs.existsSync(CONFIG_PATH)) {
console.log(chalk.yellow('⚙️ No configuration found. Running setup.'));
await initSetup();
}
await initAuth();
initEncryptionKey();
await mainMenu();
})();