Files
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)
Hashing and Encryption
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.
Press Run tests to start the app. Its log appears here.Graded endpoints
A new member of staff; the response names her and says nothing about her password. The store accepts the value only because it is a bcrypt hash
Usernames are unique
compare() ran her password through the same salt and cost and got the stored hash back
A different password hashes to something else entirely
The seeded account was hashed at boot the same way
The token verifies as before: hashing changed how a password is checked, not how a token works
Public, and empty until john adds a cat
Signed in, john adds a cat; the microchip column is empty until staff record one
Nothing to decrypt yet
The cat comes back as stored: the microchip column holds an IV and a ciphertext, not the number
The public read returns the ciphertext, which gives the number away to nobody
Decrypted with the IV stored in front of it and the key derived at boot
Any signed-in member of staff; the key belongs to the service, not to a user
The decrypted number is for staff only
The cat is looked up before anything is decrypted
The encrypted microchip goes with the record
Tom is gone