← Complete Projects

阅读中文版本

Build an Encrypted Password Vault CLI

A command-line vault that encrypts stored credentials with a passphrase-derived key using scrypt and AES-GCM, stores them in one file, and lists or reveals entries on demand.

intermediate~5 hnodejssecuritycliencryption

Project goal

Build a CLI that derives a key from a passphrase with scrypt, encrypts a credential store with AES-256-GCM, and supports add, list, and get operations without ever writing the passphrase to disk.

Final features

  • Derive a key from a passphrase with scrypt and a per-vault random salt.
  • Encrypt the whole store with AES-256-GCM and a random initialisation vector.
  • Add, list, and reveal entries only after a successful decrypt.
  • Detect a wrong passphrase or tampered ciphertext through the auth tag.
  • Never write the passphrase or the derived key to disk.

Architecture

A crypto module owns key derivation, encryption, and authenticated decryption; a vault module reads and writes the encrypted envelope; a CLI prompts for the passphrase and dispatches operations; and the envelope records the salt, IV, and auth tag alongside the ciphertext. Cryptography is isolated so operations and storage are testable with a fixed key.

Prerequisites

  • Node.js 20 LTS or later
  • Basic understanding of symmetric encryption and key derivation
  • Familiarity with authenticated encryption concepts

Dependencies

  • node >=20 — node:crypto for scrypt, AES-GCM, and randomness
  • vitest ^2.1.0 — Crypto and vault round-trip tests

Directory structure

password-vault/
├── src/
│   ├── crypto.js
│   ├── vault.js
│   ├── prompt.js
│   └── cli.js
├── test/
│   ├── crypto.test.js
│   └── vault.test.js
└── package.json

Phases

1. Key derivation

Derive a 32-byte key from a passphrase and a random salt with scrypt and sane defaults.

Files: src/crypto.js

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 });
}

Expected behavior: The same passphrase and salt yield the same key, a different salt yields a different key, and the key is exactly 32 bytes.

2. Authenticated encryption

Encrypt plaintext with AES-256-GCM and decrypt with the auth tag, rejecting tampered input.

Files: src/crypto.js

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');
}

Expected behavior: Decrypting with the same key returns the original text, a wrong key throws, and a flipped ciphertext byte throws before returning any data.

3. Vault envelope

Package the salt, IV, tag, and ciphertext into one file format and read it back.

Files: src/vault.js

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);
}

Expected behavior: Writing then reading with the same passphrase returns equal data, and reading with a wrong passphrase throws rather than returning partial data.

4. Vault operations

Implement add, list, and get over the decrypted store, keeping all mutations in memory until written back.

Files: src/operations.js

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;
}

Expected behavior: Adding a duplicate name throws, listing returns sorted names, and getting a missing entry throws a clear error.

5. Passphrase prompt

Read a passphrase from a terminal without echoing it and without writing it anywhere.

Files: src/prompt.js

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); });
  });
}

Expected behavior: The prompt returns the typed passphrase and prints no characters for the typed input, only the question and a newline.

6. CLI and tests

Dispatch add, list, and get, and cover derivation, encryption, and vault round-trips with tests.

Files: src/cli.js

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);
}

Expected behavior: Adding creates the vault file with mode 600, listing after unlocking shows the entry name, and a wrong passphrase fails without printing an entry.

Testing

Run npx vitest. Crypto tests assert that encrypt then decrypt round-trips, that a wrong key throws on decrypt, and that flipping a ciphertext byte causes the auth tag check to fail. Vault tests round-trip a store through the envelope format and assert that listing an empty vault returns nothing rather than throwing.

Troubleshooting

The vault fails to decrypt after a passphrase typo

Expect the auth tag check to fail and report a wrong-passphrase error; never fall back to treating the failure as an empty vault, which would overwrite the real data.

scrypt makes the CLI feel slow

Tune the cost parameters deliberately and keep them in the envelope; the default here is a balance point, and the parameters must be stored so future readers can derive the same key.

The vault file is world-readable

Write with mode 600 and verify the umask does not override it; protection at rest is meaningless if the file is readable by every user on the machine.

Deployment

Keep the vault file in a private directory and back it up encrypted; because the passphrase is never stored, a lost passphrase means lost data by design. In scripts, prefer prompting over passing the passphrase as an argument that lands in shell history.

Known limitations

  • The whole vault is decrypted to memory on each operation, so it is unsuitable for very large stores.
  • There is no metadata-hiding: entry names and the number of entries are only protected by whole-file encryption, not per-entry.

Project overview

Storing secrets on disk is only acceptable when the storage is authenticated encryption and the key never touches the disk. This project implements exactly that with the standard library, so the cryptography is visible and testable.

The vault derives a key from a passphrase with scrypt, encrypts the whole store with AES-256-GCM, and refuses to decrypt when the passphrase is wrong or the ciphertext has been altered.

The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.

The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.

It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.

Derivation and encryption are two small functions the rest of the project depends on:

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() };
}

Separating derivation from encryption is what makes the operations testable with an injected key while the passphrase path stays a thin wrapper.

Setup

Everything uses node:crypto, so there is no native dependency to build and no supply-chain surface for the cryptographic primitives.

Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.

Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.

Dependency Version Why it is here
node >=20 node:crypto for scrypt, AES-GCM, and randomness
vitest ^2.1.0 Crypto and vault round-trip tests

Create the project and install:

mkdir password-vault && cd password-vault
npm init -y
npm install -D vitest@^2.1.0

Configure the vault CLI and the tests:

{
  "name": "password-vault",
  "type": "module",
  "scripts": {
    "vault": "node src/cli.js",
    "test": "vitest run"
  }
}

Tests pass an explicit key so they never prompt for a passphrase and never depend on scrypt timing beyond one deliberate slow test.

Phase 1: Key derivation

Derive a 32-byte key from a passphrase and a random salt with scrypt and sane defaults. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.

The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.

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 });
}

Acceptance for this phase: The same passphrase and salt yield the same key, a different salt yields a different key, and the key is exactly 32 bytes.

Phase 2: Authenticated encryption

Encrypt plaintext with AES-256-GCM and decrypt with the auth tag, rejecting tampered input. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.

This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.

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');
}

Acceptance for this phase: Decrypting with the same key returns the original text, a wrong key throws, and a flipped ciphertext byte throws before returning any data.

Phase 3: Vault envelope

Package the salt, IV, tag, and ciphertext into one file format and read it back. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.

The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.

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);
}

Acceptance for this phase: Writing then reading with the same passphrase returns equal data, and reading with a wrong passphrase throws rather than returning partial data.

Phase 4: Vault operations

Implement add, list, and get over the decrypted store, keeping all mutations in memory until written back. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.

Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.

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;
}

Acceptance for this phase: Adding a duplicate name throws, listing returns sorted names, and getting a missing entry throws a clear error.

Phase 5: Passphrase prompt

Read a passphrase from a terminal without echoing it and without writing it anywhere. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.

The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.

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); });
  });
}

Acceptance for this phase: The prompt returns the typed passphrase and prints no characters for the typed input, only the question and a newline.

Phase 6: CLI and tests

Dispatch add, list, and get, and cover derivation, encryption, and vault round-trips with tests. Each phase produces something demonstrable, so the tutorial never asks the reader to hold an unfinished design in their head.

The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.

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);
}

Acceptance for this phase: Adding creates the vault file with mode 600, listing after unlocking shows the entry name, and a wrong passphrase fails without printing an entry.

Design trade-offs

Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.

Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.

Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.

Testing strategy

Run npx vitest. Crypto tests assert that encrypt then decrypt round-trips, that a wrong key throws on decrypt, and that flipping a ciphertext byte causes the auth tag check to fail. Vault tests round-trip a store through the envelope format and assert that listing an empty vault returns nothing rather than throwing.

Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.

Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.

Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.

The tamper test is the whole reason for authenticated encryption: without the auth tag check, a modified ciphertext would decrypt to garbage and the vault would silently corrupt.

Extending the project

Per-entry encryption, categories, and import from other formats are additions to the store shape. A stronger KDF cost or a second key for rotation is a change to the crypto module alone, because operations never touch the primitives directly.

The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.