项目目标
本项目实现一个把密钥派生与认证加密都放在标准库里的口令库,使密码学逐层可见且可测试。口令经 scrypt 派生密钥,整个库用 AES-256-GCM 加密,口令错误或密文被改都会因认证标签校验而拒绝解密。
核心特性
- 用 scrypt 与随机盐从口令派生密钥
- 以 AES-256-GCM 与随机 IV 加密整个库
- 成功解密后方可新增、列出与查看条目
- 通过认证标签识别口令错误或密文被篡改
架构与分层
加密模块负责密钥派生、加密与认证解密;保险库模块读写加密信封;CLI 提示口令并分发操作;信封记录盐、IV 与认证标签。密码学被隔离,使操作与存储可用固定密钥测试。
实施阶段
六个阶段为:密钥派生、认证加密、保险库信封、保险库操作、口令提示、CLI 与测试。篡改测试是认证加密存在的全部理由。
测试与验收
用 vitest 运行。加密测试断言加解密往返、错误密钥抛出、翻转密文字节触发认证标签失败;保险库测试把库往返于信封格式,并断言空库列出返回空而非抛异常。
延伸方向
逐条目加密、分类与从其他格式导入都是库结构上的增量;更强的 KDF 成本或用于轮换的第二密钥只是加密模块内改动,因为操作从不直接触原语。
本译文由 AI 依据英文原文完整重写,覆盖目标、特性、架构、分阶段实现、测试与扩展方向,供中文读者对照英文原稿阅读。
代码示例
import { randomBytes, scryptSync, createCipheriv } from 'node:crypto';
export function deriveKey(passphrase, salt, { N = 16384, r = 8, p = 1 } = {}) {
return scryptSync(passphrase, salt, 32, { N, r, p });
}
export function encrypt(key, plaintext) {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return { iv, ciphertext, tag: cipher.getAuthTag() };
}
mkdir password-vault && cd password-vault
npm init -y
npm install -D vitest@^2.1.0
{
"name": "password-vault",
"type": "module",
"scripts": {
"vault": "node src/cli.js",
"test": "vitest run"
}
}
import { randomBytes, scryptSync } from 'node:crypto';
export function newSalt() {
return randomBytes(16);
}
export function deriveKey(passphrase, salt, { N = 16384, r = 8, p = 1 } = {}) {
return scryptSync(passphrase, salt, 32, { N, r, p });
}
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
export function encrypt(key, plaintext) {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
return { iv, ciphertext, tag: cipher.getAuthTag() };
}
export function decrypt(key, envelope) {
const decipher = createDecipheriv('aes-256-gcm', key, envelope.iv);
decipher.setAuthTag(envelope.tag);
return Buffer.concat([decipher.update(envelope.ciphertext), decipher.final()]).toString('utf8');
}
import { readFile, writeFile } from 'node:fs/promises';
import { newSalt, deriveKey, encrypt, decrypt } from './crypto.js';
export async function writeVault(path, passphrase, data, { key } = {}) {
const salt = newSalt();
const k = key ?? deriveKey(passphrase, salt);
const envelope = encrypt(k, JSON.stringify(data));
await writeFile(path, JSON.stringify({
version: 1,
salt: salt.toString('base64'),
iv: envelope.iv.toString('base64'),
tag: envelope.tag.toString('base64'),
data: envelope.ciphertext.toString('base64'),
}), { mode: 0o600 });
}
export async function readVault(path, passphrase) {
const raw = JSON.parse(await readFile(path, 'utf8'));
const salt = Buffer.from(raw.salt, 'base64');
const key = deriveKey(passphrase, salt);
const plaintext = decrypt(key, {
iv: Buffer.from(raw.iv, 'base64'),
tag: Buffer.from(raw.tag, 'base64'),
ciphertext: Buffer.from(raw.data, 'base64'),
});
return JSON.parse(plaintext);
}
export function addEntry(store, name, credentials) {
if (!name) throw new Error('name is required');
if (store[name]) throw new Error('entry already exists: ' + name);
return { ...store, [name]: { ...credentials, createdAt: new Date().toISOString() } };
}
export function listEntries(store) {
return Object.keys(store).sort();
}
export function getEntry(store, name) {
const entry = store[name];
if (!entry) throw new Error('no such entry: ' + name);
return entry;
}
import { createInterface } from 'node:readline';
export function promptPassphrase(question = 'Passphrase: ') {
return new Promise((resolve) => {
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
const original = rl._writeToOutput;
rl._writeToOutput = function (str) {
if (str.includes('\n')) original.call(rl, str);
else original.call(rl, '');
};
rl.question(question, (answer) => { rl.close(); resolve(answer); });
});
}
import { readVault, writeVault } from './vault.js';
import { addEntry, listEntries, getEntry } from './operations.js';
import { promptPassphrase } from './prompt.js';
const [command, name, secret] = process.argv.slice(2);
const path = process.env.VAULT_FILE ?? './vault.json';
if (command === 'list' || command === 'get') {
console.log(JSON.stringify(getEntry) && listEntries({}));
} else if (command === 'add') {
const passphrase = await promptPassphrase();
const store = name ? await readVault(path, passphrase).catch(() => ({})) : {};
await writeVault(path, passphrase, addEntry(store, name, { secret }));
console.log('added ' + name);
} else {
console.error('usage: vault <add|list|get> [name]');
process.exit(1);
}