Ashley Buckingham
Notes / Projects / Vault

Vault

Published:
Intended audience:
Developers

I clear out repos I'm not working on to keep my machine tidy. The code lives on GitHub, so that's fine. The .env file doesn't, and every time I come back to an old project I lose time rebuilding it from dashboards and password managers.

Vault fixes that. It's a local-first CLI: save a secret file into an encrypted vault, delete the repo, and restore the file later when I need it. The vault folder only holds ciphertext, so it can live in a private git repo and follow me to a new laptop.

It's about 1,200 lines of plain JavaScript with no runtime dependencies. Everything comes from Node's standard library: crypto, fs, readline and node:test. For a tool that handles secrets, every package left out is one less thing to trust.

Using it

The first save sets up the vault: it asks for a passphrase and enrols a TOTP authenticator.

terminal
vault set-vault --vault ~/Documents/vault --ttl 600
vault save ./my-app/.env --remove
vault load

save encrypts the file and, with --remove, deletes the plaintext. load restores it into the current folder. Unlocking starts a session, ten minutes by default, so I'm not typing codes for every command.

Envelope encryption

The passphrase never encrypts a file directly. There are two keys:

  • KEK (key-encryption key): derived from the passphrase with PBKDF2-HMAC-SHA256, 210,000 iterations and a random 16-byte salt.
  • DEK (data-encryption key): 32 random bytes, generated once per vault. This is the key that encrypts every entry.

The KEK only does one job: it wraps the DEK, and the TOTP seed, with AES-256-GCM. Both wrapped values live in the vault's manifest.

fig. 1: envelope encryption
src/crypto-utils.js
function deriveKek(passphrase, salt, iterations) {
  return crypto.pbkdf2Sync(passphrase, salt, iterations, KEY_BYTES, "sha256");
}

The split pays off later. Changing the passphrase only means re-wrapping one 32-byte key instead of re-encrypting every file. And because the iteration count is stored in the manifest rather than hard-coded, it can be raised for new vaults without breaking old ones.

The files on disk

A vault is an ordinary folder of JSON files:

~/Documents/vault
vault.json                 # salt, KDF settings, wrapped DEK, wrapped TOTP seed
my-app.env.enc.json        # one file per saved secret
another-api.env.enc.json

Each entry is encrypted with AES-256-GCM under the DEK, with a fresh random 12-byte IV every time. GCM's 16-byte auth tag is stored next to the ciphertext and checked on decrypt, so a flipped bit fails loudly instead of producing garbage.

my-app.env.enc.json
{
  "version": 2,
  "algorithm": "aes-256-gcm",
  "iv": "…",
  "authTag": "…",
  "ciphertext": "…",
  "entryName": "my-app",
  "sourceFile": ".env",
  "sourceDir": "/Users/me/code/my-app",
  "createdAt": "2026-02-11T17:00:00.000Z"
}

JSON over a binary format was deliberate. It diffs readably in git, and I can see what's in a vault without decrypting anything. That's also a trade-off: the metadata (file name and folder) sits in plain text.

TOTP from scratch

The authenticator code is RFC 6238 implemented by hand in about 100 lines: a base32 encoder and decoder, HMAC-SHA1 over a 64-bit time counter, the RFC 4226 dynamic truncation, and six digits. It accepts one 30-second step either side for clock drift, and compares codes with crypto.timingSafeEqual so the check doesn't leak how many digits matched.

src/totp.js
for (let skew = -1; skew <= 1; skew += 1) {
  const candidate = generateTotpCode(secretBase32, now + skew * TOTP_PERIOD_SECONDS);
  if (timingSafeStringEqual(candidate, code)) return true;
}

The seed is 20 random bytes, encrypted under the KEK, so it never touches the disk in plain text.

fig. 2: the TOTP drift window

What the TOTP actually protects

Writing this note made me be honest about it. The TOTP is a gate in the CLI, not part of the key. To check a code, Vault first derives the KEK from the passphrase, then decrypts the TOTP seed and the DEK together.

  • It does stop someone who has learned my passphrase, from shell history or over my shoulder, and only has the CLI.
  • It doesn't stop an offline attack. Anyone with vault.json and the passphrase can unwrap the DEK in a few lines of Node and never be asked for a code.

So the real security rests on the passphrase and PBKDF2. Mixing the TOTP into the key wouldn't help either, since the seed has to be stored somewhere the CLI can read it. The fix for offline attacks is a stronger passphrase and a slower KDF, not more factors.

Sessions

Unlocking writes a session file to ~/.vault/sessions/, named by a SHA-256 hash of the vault's path, so the folder doesn't reveal where my vaults live. It holds the DEK and an expiry time, written with 0600 permissions.

The expiry is only checked when the CLI next runs, at which point an expired session is deleted. vault lock removes it immediately.

fig. 3: a session’s lifetime

The threat model says it plainly: an unlocked, compromised machine is out of scope, and that folder should be treated like the secrets themselves.

Prompts without a library

Hidden passphrase input usually means a dependency. Instead I override readline's output so each keystroke echoes *, then drop the answer from readline's history.

src/prompts.js
rl._writeToOutput = function writeToOutput(output) {
  if (!wrotePrompt) { rl.output.write(output); wrotePrompt = true; return; }
  if (rl.stdoutMuted) rl.output.write("*");
  else rl.output.write(output);
};

Picking an entry is a small search loop. Type a number to choose, or any text to filter by entry name, file name or folder. When the filter leaves one match, it's chosen automatically.

When there's no terminal, as in a setup script, prompts refuse to run. The passphrase and code come from environment variables instead.

terminal
export ENV_PULL_PASS='your-passphrase'
export ENV_PULL_TOTP='123456'
vault unlock --passphrase-env ENV_PULL_PASS --totp-env ENV_PULL_TOTP

Versioning the format

The first version encrypted each entry straight from the passphrase, each with its own salt, and had no manifest or TOTP. Version 2 moved to envelope encryption. Every file records its version, so load can still read old entries by asking for the passphrase they were made with.

src/app.js
if (selected.payload.version === 1) {
  const legacyPassphrase = await resolveSecret(PASS_ENV_ARG, options, "Enter legacy entry passphrase: ");
  plaintext = decryptLegacyEntry(selected.payload, legacyPassphrase);
}

Testing

The tests use node:test and node:assert, still with no dependencies.

  • Crypto: round trips, a hand-built v1 entry, and a wrong key being rejected.
  • TOTP: base32 round trips, codes verified at a fixed time, bad input rejected.
  • End to end: the CLI runs as a child process against a temporary config. It saves a file from one repo with --remove, lists it, restores it into a different repo through the search prompt, then deletes it. That test has its own separate TOTP implementation, so the two cross-check each other.

What I'd change next

  • Authenticate the metadata. Entries don't use GCM's additional authenticated data, so someone with write access to the vault repo could swap ciphertexts between entries or edit sourceFile undetected. Binding the metadata as AAD, and refusing paths that leave the current folder, would close both.
  • More PBKDF2 iterations, or scrypt. OWASP now suggests 600,000 iterations for PBKDF2-SHA256. Node ships scrypt, which is memory-hard, so there's no dependency cost.
  • Atomic writes. Writing to a temporary file and renaming it would stop a crash halfway through from corrupting vault.json.
  • Replay protection. Storing the last accepted time step would stop a code being reused within its window.
  • A QR code at enrolment. Right now the seed is printed as base32, and an otpauth:// URI would make phone set-up one scan.

Looking back

  • Write the threat model first. Deciding what Vault protects against, and what it doesn't, settled most of the design before I wrote any crypto. It also made the TOTP's limits obvious once I looked.
  • Envelope encryption is cheap and worth it. One extra key buys passphrase changes and KDF upgrades almost for free.
  • The standard library is enough. Key derivation, authenticated encryption, HMAC, hidden prompts and a test runner all come with Node.