← 中文资源

简体中文版本

构建加密密码保险库命令行工具

用 scrypt 派生密钥、以 AES-GCM 加密凭据并支持增、列、取,且口令从不落盘。

阅读英文原文

项目目标

本项目实现一个把密钥派生与认证加密都放在标准库里的口令库,使密码学逐层可见且可测试。口令经 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);
}