Building a personal, encrypted password keychain in Ruby
A weekend project: a tiny command-line password vault built on Ruby and OpenSSL. A thorough tour of key derivation, IVs, authenticated encryption, constant-time comparison — and every way we found to get crypto silently, confidently wrong.
Every developer eventually has the same itch: a pile of passwords scattered across notes and browser autofill, and a nagging sense that there must be a cleaner way. So one weekend we scratched it — a small command-line keychain in Ruby, encrypted with a single master passphrase. It is the kind of project that looks trivial and turns out to be a crash course in applied cryptography.
The disclaimer first, because it genuinely matters: do not use a homegrown vault for anything you cannot afford to lose. This was a learning exercise, and the value was entirely in discovering how many ways there are to write encryption code that runs perfectly and is completely insecure. That gap — between “works” and “safe” — is the whole story, and it is the best argument for the advice everyone repeats and few internalise: don’t roll your own crypto. Here is what the project taught us, mistake by mistake.
Start with a threat model, not a cipher
The first instinct is to reach for AES. The right first move is to write down what you are actually defending against, because that decides everything else. For a personal vault the realistic threats are: someone gets the file off your disk, a stolen backup, a snooping process reading it at rest. The vault is not trying to defend against an attacker who has compromised your running machine and can read your memory or keylog the master passphrase — nothing a file-based tool does will save you there.
That scoping tells us what we need: confidentiality of the file at rest, integrity (tamper-detection) of the file, and a master passphrase that is expensive to brute-force. It also tells us what we do not need to invent: a key-exchange protocol, asymmetric crypto, forward secrecy. Knowing what you are not building is half of not over-engineering it.
The shape of the thing
The design is deliberately boring. Secrets live in a single JSON blob. The blob is encrypted with a key derived from your master passphrase. On disk you only ever see ciphertext:
require "openssl"
require "json"
require "base64"
class Vault
CIPHER = "aes-256-cbc"
def initialize(path, passphrase)
@path = path
@passphrase = passphrase
end
def entries
return {} unless File.exist?(@path)
JSON.parse(decrypt(File.binread(@path)))
end
def store(name, secret)
data = entries.merge(name => secret)
File.binwrite(@path, encrypt(JSON.generate(data)))
end
end
Clean enough. The danger is entirely inside encrypt and decrypt, and that is
where every interesting mistake lives.
Mistake 1: encrypting with the passphrase directly
The tempting first version feeds the passphrase straight into the cipher as a key. It is wrong on two counts. A 256-bit AES key needs 32 bytes of uniformly random material; a human passphrase is neither 32 bytes nor uniform — it is short, low-entropy, and structured. You need a key derivation function to stretch the passphrase into a proper key and, crucially, to make each brute-force guess expensive. In 2013 the pragmatic, widely-available choice is PBKDF2 with a high iteration count and a random per-vault salt:
def derive_key(salt)
OpenSSL::PKCS5.pbkdf2_hmac_sha1(
@passphrase, salt,
100_000, # iterations — deliberately slow
32 # 256-bit key
)
end
Three parameters, each load-bearing. The salt is not secret; it is stored with the ciphertext. Its job is to ensure two vaults with the same passphrase produce different keys, defeating precomputed rainbow tables and making each vault’s cracking a separate effort. The iteration count is your dial against brute force: high enough to cost a noticeable fraction of a second per attempt, which you will never notice unlocking your own vault once, and which is ruinous to someone testing millions of candidate passwords. Tune it to your hardware — if it is imperceptible, raise it. The output length must match your cipher’s key size exactly.
A note for the present: PBKDF2 is only compute-hard, so a GPU or ASIC attacker parallelises it cheaply. The memory-hard alternatives (scrypt exists in 2013; bcrypt for password hashing) raise the cost of custom hardware and are the better choice where available. The principle is the same — make guessing expensive — but how expensive depends on the KDF you pick.
Mistake 2: a constant or reused initialisation vector
CBC mode needs an initialisation vector, and the failure here is subtle. If you encrypt the same plaintext twice with the same key and the same IV, you get identical ciphertext — which leaks that the data did not change between saves, and in some constructions enables outright attacks. The rule is absolute: a fresh, random IV for every single encryption, generated by the cipher itself, and stored (in the clear — it is not secret) alongside the output.
def encrypt(plaintext)
salt = OpenSSL::Random.random_bytes(16)
cipher = OpenSSL::Cipher.new(CIPHER).encrypt
cipher.key = derive_key(salt)
iv = cipher.random_iv # fresh every call
ciphertext = cipher.update(plaintext) + cipher.final
# pack salt + iv + ciphertext so we can reverse it later
[salt, iv, ciphertext].map { |p| Base64.strict_encode64(p) }.join("\n")
end
def decrypt(blob)
salt, iv, ciphertext = blob.split("\n").map { |p| Base64.strict_decode64(p) }
cipher = OpenSSL::Cipher.new(CIPHER).decrypt
cipher.key = derive_key(salt)
cipher.iv = iv
cipher.update(ciphertext) + cipher.final
end
Notice we store the salt and IV with the ciphertext. Beginners hide them, or far worse hard-code a constant IV “to keep the format simple”. Both are wrong: the salt and IV are designed to be public, and security rests entirely on the secrecy of the passphrase and the randomness of these values — never on hiding them. (CBC’s padding, PKCS#7, is handled by OpenSSL here; it is also the seam behind the infamous padding-oracle attacks, which is one more reason the next section is not optional.)
Mistake 3 — the one we shipped: encryption without authentication
Here is the bug we wrote and only caught later, because the code worked flawlessly the whole time. CBC encryption gives you confidentiality: an attacker cannot read the plaintext. It gives you no integrity: an attacker — or a flipped bit on a failing disk — can tamper with the ciphertext, and on decryption you get garbage or, in some constructions, output an attacker can influence. A real vault needs authenticated encryption: it must detect that the bytes changed and refuse to decrypt.
The clean fix is Encrypt-then-MAC. After encrypting, compute an HMAC over the ciphertext (and the IV and salt) with a second derived key, store it, and verify it before you ever attempt to decrypt:
def mac(key, *parts)
OpenSSL::HMAC.digest("SHA256", key, parts.join)
end
# on decrypt, BEFORE touching the cipher:
expected = mac(mac_key, salt, iv, ciphertext)
unless OpenSSL.secure_compare(expected, stored_mac)
raise "vault has been tampered with, or the passphrase is wrong"
end
Two subtleties. Use a separate key for the MAC (derive two keys from the KDF, or
derive a master key and split it) — never the same key for encryption and
authentication. And the comparison must be constant-time: a naive == returns
as soon as two bytes differ, leaking through timing where the first mismatch is,
which over many tries lets an attacker forge a valid tag byte by byte.
OpenSSL.secure_compare (or Rack::Utils.secure_compare) compares in time
independent of the contents.
Today you would skip this whole hand-rolled dance and use AES-256-GCM, an authenticated cipher mode that folds integrity into encryption and produces an authentication tag for you — removing the separate-MAC, the key-splitting, and the timing-comparison footguns in one move. But building the Encrypt-then-MAC version by hand is exactly what makes you understand why GCM is the default advice.
Handling the master passphrase
The strongest crypto is undone by careless key handling. A few habits the project
forced on us: read the passphrase without echoing it (IO::console#noecho), do
not pass it as a command-line argument (it lands in your shell history and the
process list for any user to see), and do not keep it or the derived key in memory
longer than the operation needs. When the tool prints a secret, prefer copying it
to the clipboard with a short auto-clear timeout over dumping it to a terminal
that scrolls into a logged session. None of this is cryptography; all of it is the
difference between a vault and a vault-shaped hole.
What the weekend was actually worth
The keychain works. It lives in a single file, it is encrypted and authenticated
at rest, and typing the wrong master passphrase now fails loudly instead of
returning rubbish. But the artefact is not the lesson. The lesson is the catalogue
of ways a confident developer produces code that runs perfectly and is completely
insecure: the passphrase used as a raw key, the constant IV, the missing MAC, the
timing-leaky comparison, the passphrase left in ARGV. Every one of those
compiles, runs, and round-trips your data correctly. None of them is safe.
That is the real takeaway, and it is precisely why “don’t roll your own crypto” is good advice — not because the primitives are unusable, but because the gaps between them are invisible until you have personally fallen into each one. Build a toy vault once, on your own time, to see the gaps. Then go use a library that has already closed them, and respect it a great deal more for the quiet, unglamorous work it is doing on your behalf.