Hashing and Encryption
Stop storing passwords: hash them with bcrypt at registration and compare at sign-in. Then the other direction, for data that must be read back: encrypt each cat's microchip number with AES under a key derived with scrypt, a fresh IV per record.
What you'll learn
- Tell hashing from encryption, and say which one a password gets and which one data you must read back gets
- Hash a password with bcrypt at a chosen cost and check a sign-in with compare, never by comparing strings
- Derive an encryption key from a password with scrypt and encrypt with aes-256-ctr under a fresh IV per message, storing the IV with the ciphertext
- Read the shape of a bcrypt hash and of a stored ciphertext, and know what each gives away (nothing)
Lesson 1 left a line that the docs themselves warn about: user.password !== pass. The staff table holds changeme in clear text. Should that table ever leak, through a backup, a log, a stray query, every password in it is public, and most people reuse theirs. The fix is not to store passwords at all. The shelter also has data it must be able to read back, a cat's microchip number, which it would rather not keep in clear text either. Those are two different problems with two different tools, and confusing them is a classic mistake.
One way and two ways
Hashing turns an input into a fixed-size output and cannot be run backwards. Given $2b$10$EUR0d7W..., nobody can recover changeme; but given changeme, anybody can compute the hash again and compare. That is exactly what a sign-in needs: the server keeps the hash, the visitor sends the password, the server hashes it and checks the two match. The password itself is never stored.
Encryption is two-way. Ciphertext turns back into plaintext for whoever holds the key. That is what data you must read back needs: the microchip number is encrypted when it is recorded and decrypted when staff ask for it. The key is the secret, and it lives with the service, not with a user.
A password is hashed, never encrypted, because a stored key would make every password recoverable. A microchip number is encrypted, never hashed, because a hash could never be read back.
bcrypt
A general-purpose hash such as SHA-256 is the wrong hash for passwords: it is designed to be fast, and fast is what an attacker with a leaked table wants, billions of guesses a second. bcrypt is designed to be slow, by a cost you choose, and to be different for every user, by a random salt it generates and keeps inside the hash:
import * as bcrypt from 'bcrypt';
const hash = await bcrypt.hash('changeme', 10);
// $2b$10$X8w8PdpFyzDQj469RH1Vc.jsVfa52ae29IZxQjYBrbQIIRzkpEDnS
const ok = await bcrypt.compare('changeme', hash); // true
const no = await bcrypt.compare('CHANGEME', hash); // false
Read the hash: $2b$ is the version, 10 the cost, the next 22 characters the salt, the rest the digest. Cost 10 means 2^10 rounds, about a quarter of a second here; each step up doubles it. compare(password, hash) reads the salt and cost out of the hash, recomputes, and answers in constant time. The order of its arguments is password first, hash second; the other way round it quietly returns false for every sign-in. Hashing the same password twice gives two different hashes, because of the salt, which is why two users with the same password do not have the same hash, and why the check must be compare, never ===.
scrypt and AES
Encrypting takes a key of exact length, 32 bytes for AES-256. People do not remember 32 random bytes, so the key is derived from a password with a function built for it. The docs use scrypt, another deliberately slow function; promisify turns its callback form into a promise:
import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'node:crypto';
import { promisify } from 'node:util';
const key = (await promisify(scrypt)('a password from configuration', 'a salt', 32)) as Buffer;
Deriving is slow on purpose, so an app does it once, at start, not per request. Then the docs' example encrypts a string with AES-256-CTR:
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-ctr', key, iv);
const encrypted = Buffer.concat([cipher.update('Nest'), cipher.final()]);
const decipher = createDecipheriv('aes-256-ctr', key, iv);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
decrypted.toString(); // 'Nest'
The IV (initialisation vector) is 16 random bytes that make the same plaintext encrypt differently every time. It is not secret, but it is essential: decryption needs the same IV, so it is stored beside the ciphertext, and it must be fresh for every message, because in CTR mode two messages under one IV and one key leak each other. update() and final() return Buffers; toString('hex') makes them storable text and Buffer.from(text, 'hex') reads them back.
Your task
Staff register with a password that is hashed, and record microchip numbers that are encrypted.
- In
auth/auth.service.ts, hash the password with bcrypt at cost 10 before storing it, and at sign-in check the password against the stored hash the way bcrypt does.UsersServicerefuses anything that is not a bcrypt hash, so a stored password fails loudly. - In
crypto/crypto.service.ts, derive a 32-byte key with scrypt from the password and salt inconstants.tswhen the module starts.encryptusesaes-256-ctrwith a fresh 16-byte IV and returns the IV and the ciphertext as hex joined by a colon;decryptreverses that.
The cats routes are already wired: POST /cats/:id/microchip stores what encrypt returns, GET /cats/:id/microchip returns what decrypt gives back. After Run, read the cat through the request panel and look at what the microchip column holds.
When it fails
POST /auth/registeris a 500 and the console saysUsersService stores bcrypt hashes only: the password reached the store unhashed.- Every sign-in is 401, even the right password: the hash is compared with
===, which is never true, orcomparegot its arguments the wrong way round. Invalid key length: the derived key is not 32 bytes; the third argument ofscryptis the key length in bytes.Invalid initialization vector: the IV is not 16 bytes, or the hex in front of the ciphertext was not turned back into bytes beforecreateDecipheriv.- Decrypting returns gibberish: a different IV than the one used to encrypt, because it was not stored or was generated again on the way back.
this.keyis undefined: the key is derived in a method nobody awaits;onModuleInitis where Nest waits for it.
Remember
- Passwords are hashed with a slow, salted hash (bcrypt,
hash(password, cost)) and checked withcompare(password, hash); nothing else is stored. - Data that must be read back is encrypted; the key is the service's secret, derived once with scrypt.
- AES needs a fresh random IV per message, stored beside the ciphertext, never reused.
Buffer.concat([update(), final()]),toString('hex')out andBuffer.from(hex, 'hex')back in.
Stuck? Show a hint
auth.service.ts: await bcrypt.hash(password, 10) before create(); await bcrypt.compare(pass, user.passwordHash) when signing in (password first, hash second). crypto.service.ts: this.key = (await promisify(scrypt)(password, salt, 32)) as Buffer; encrypt: const iv = randomBytes(16); createCipheriv('aes-256-ctr', this.key, iv); Buffer.concat([cipher.update(text), cipher.final()]); return iv.toString('hex') + ':' + encrypted.toString('hex'). decrypt: split on ':', Buffer.from(hex, 'hex') for both, createDecipheriv with the same algorithm and key.