Encryption & Hashing Lab
Estimated time: 3–4 hours | Difficulty: Intermediate
What You Will Learn
- Understand why storing passwords as plain text is catastrophic
- Use Java’s
MessageDigestto hash strings with SHA-256 - Explain the avalanche effect and why hash functions are one-way
- Understand rainbow table attacks and why salting defeats them
- Use Spring Security’s
BCryptPasswordEncoderfor real-world password hashing - Distinguish between hashing (one-way) and encryption (two-way)
- Implement AES symmetric encryption and RSA asymmetric encryption in Java
- Explain how HTTPS uses both asymmetric and symmetric encryption
- Build a simple bcrypt-based password registration and login system
1. Why Passwords Cannot Be Stored as Text
Imagine you sign up for a website. You type in your email, choose a password, and click “Create Account.” What happens next? The obvious approach — the one that seems perfectly reasonable if you have never thought about security — is for the website to store your email and password directly in a database table. Something like this:
-- THIS IS HOW YOU SHOULD NEVER STORE PASSWORDS
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL
);
INSERT INTO users (email, password) VALUES
('alice@example.com', 'sunshine99'),
('bob@example.com', 'qwerty123'),
('carol@example.com', 'ilovecats!');
This is called plain text storage, and it is an absolute catastrophe waiting to happen. Here is why: databases get breached. It is not a question of if but when. Attackers find SQL injection vulnerabilities. Employees make mistakes. Backups get left on unsecured servers. When a database is breached and passwords are stored in plain text, the attacker instantly has every single username and password combination. No cracking required. No computation needed. They just read them.
This is not a hypothetical scenario. It has happened repeatedly to massive companies:
- LinkedIn, 2012: 6.5 million password hashes were leaked. They were using unsalted SHA-1 — barely better than plain text. Within hours, security researchers had cracked the majority of them. The breach later turned out to affect 117 million accounts.
- Adobe, 2013: 153 million user records were stolen. Adobe used reversible encryption (not hashing) with a single key for all passwords, and stored password hints in plain text. Researchers could figure out common passwords just by looking at the hints. The password “123456” appeared 1.9 million times.
- RockYou, 2009: 32 million passwords stored in completely plain text. When the database was breached, every single password was immediately readable. This database became one of the most widely used wordlists for password cracking tools.
So if you cannot store the password itself, what do you store? The answer is: you do not store the password at all. You store a hash of the password. And that is what this entire lesson is about.
2. What Is Hashing?
A hash function is a mathematical function that takes any input — a string, a file, an entire novel — and produces a fixed-size output called a hash (also called a digest or fingerprint). The input can be any length, but the output is always the same length. For SHA-256, that output is always exactly 256 bits, which is 64 hexadecimal characters.
Hash functions have three critical properties that make them useful for security:
Property 1: Deterministic
The same input always produces the same output. If you hash the string "password123" with SHA-256 today, tomorrow, or ten years from now, you will always get exactly the same 64-character hex string. This is what makes verification possible — when a user logs in, you hash what they typed and compare it to the stored hash.
Property 2: One-Way (Pre-image Resistance)
Given a hash output, it is computationally infeasible to figure out what the original input was. You cannot “reverse” a hash. You cannot “decrypt” it. The mathematical operations involved destroy information in a way that cannot be reconstructed. The only way to find the input is to guess inputs and hash them until you find a match — which is exactly what attackers try to do.
Property 3: Avalanche Effect
Changing even a single character in the input produces a completely different hash. The outputs look entirely unrelated. There is no pattern, no gradual change, no way to look at two hashes and determine whether their inputs were similar. This prevents attackers from “getting close” to the right answer.
Let us see this in action. The following Java program uses MessageDigest to hash two nearly identical strings with SHA-256:
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class HashDemo {
public static void main(String[] args) throws NoSuchAlgorithmException {
String input1 = "password123";
String input2 = "password124";
String hash1 = sha256(input1);
String hash2 = sha256(input2);
System.out.println("Input: " + input1);
System.out.println("SHA-256: " + hash1);
System.out.println();
System.out.println("Input: " + input2);
System.out.println("SHA-256: " + hash2);
System.out.println();
// Demonstrate determinism: hash the first input again
String hash1Again = sha256(input1);
System.out.println("Hash input1 again: " + hash1Again);
System.out.println("Same as before? " + hash1.equals(hash1Again));
}
public static String sha256(String input) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(input.getBytes());
// Convert byte array to hexadecimal string
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
}
}
Run that program. You will see that "password123" produces the hash ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f. And "password124" — which differs by just one character — produces something completely different. There is no similarity between the two hashes whatsoever. That is the avalanche effect.
Also notice that hashing "password123" a second time produces the exact same hash. That is determinism. This is how login verification works: the server hashes what the user typed and checks if it matches the stored hash. If it matches, the password is correct. The server never needs to know or store the actual password.
Let us walk through the code. MessageDigest.getInstance("SHA-256") creates a SHA-256 hash function. The digest() method takes a byte array and returns the hash as a byte array. We then convert each byte to its two-character hexadecimal representation. The 0xff & b handles Java’s signed bytes correctly (bytes in Java range from -128 to 127, but we need unsigned values 0 to 255).
- MD5: Produces a 128-bit (32-character hex) hash. Broken for security purposes. Collisions (two different inputs producing the same hash) can be generated in seconds. Never use MD5 for passwords or security. It is still sometimes used for file integrity checksums.
- SHA-1: Produces a 160-bit (40-character hex) hash. Also broken — Google demonstrated a practical collision in 2017 (the “SHAttered” attack). Deprecated for security use.
- SHA-256: Part of the SHA-2 family. Produces a 256-bit (64-character hex) hash. Currently secure for general hashing, but too fast for password hashing (we will explain why shortly).
- bcrypt, scrypt, Argon2: Purpose-built password hashing functions. They are intentionally slow and include built-in salting. These are what you should use for passwords in real applications.
3. Why Simple Hashing Is Not Enough
You might think we are done. Just hash every password with SHA-256 before storing it, and the database is safe. Sadly, it is not that simple. Simple hashing has a devastating weakness: rainbow tables.
A rainbow table is a massive pre-computed database that maps hash values back to their original inputs. Someone has already hashed millions — sometimes billions — of common passwords, dictionary words, and combinations, and stored the results. If your password hash appears in a rainbow table, it can be “cracked” instantly by looking it up.
Remember the SHA-256 hash of "password123"? That hash — ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f — is in every rainbow table on the internet. An attacker who steals your database does not need to “crack” it. They just look it up, and within milliseconds they know the password was "password123".
It gets worse. Because hash functions are deterministic, every user who chose the password "password123" has exactly the same hash in the database. An attacker can identify every account that shares the same password just by looking for duplicate hashes. If the most common hash appears 10,000 times, you can bet that password is "123456".
The speed of SHA-256 makes this even more dangerous. Modern GPUs can compute billions of SHA-256 hashes per second. An attacker can hash every word in a dictionary, every 8-character combination, every leaked password from previous breaches, and build a comprehensive lookup table in hours.
The solution is salting.
What Is a Salt?
A salt is a random string of characters that is generated uniquely for each password. Before hashing, the salt is combined with the password, and the result is hashed. The salt is then stored alongside the hash in the database. When verifying a password, you retrieve the salt, combine it with the user’s input, hash the result, and compare it to the stored hash.
Because every password gets a unique random salt, two users who chose the same password will have completely different hashes. Rainbow tables become useless because the attacker would need a separate rainbow table for every possible salt — which is computationally impossible.
This is exactly what bcrypt does, and it does it automatically. You never generate or manage the salt yourself. Bcrypt generates a random salt, hashes the password with that salt, and encodes the salt directly into the output string. When you verify a password, bcrypt extracts the salt from the stored hash and uses it automatically.
Here is how it works in Java using Spring Security’s BCryptPasswordEncoder:
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class BcryptDemo {
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
String password = "password123";
// Hash the same password twice
String hash1 = encoder.encode(password);
String hash2 = encoder.encode(password);
System.out.println("Password: " + password);
System.out.println();
System.out.println("Hash 1: " + hash1);
System.out.println("Hash 2: " + hash2);
System.out.println();
System.out.println("Are the hashes the same? " + hash1.equals(hash2));
System.out.println();
// But BOTH hashes verify against the original password
System.out.println("Hash 1 matches password? " + encoder.matches(password, hash1));
System.out.println("Hash 2 matches password? " + encoder.matches(password, hash2));
System.out.println();
// Wrong password does NOT match
System.out.println("Wrong password matches? " + encoder.matches("wrongpassword", hash1));
}
}
Run this code and something remarkable happens. You hash the same password twice, and you get two completely different hashes. This seems contradictory — did we not just say hash functions are deterministic? They are. But bcrypt adds a unique random salt each time. The actual input to the hash function is different each time because the salt is different.
Yet both hashes verify correctly against the original password. The matches() method extracts the salt embedded in the hash, re-hashes the password with that salt, and compares. This is the magic of bcrypt: different hashes for the same password, but reliable verification every time.
Look at a bcrypt hash closely: $2a$10$N9qo8uLOickgx2ZMRZoMye.... The $2a$ identifies the bcrypt version. The $10$ is the cost factor (also called work factor or rounds) — it controls how slow the hashing is. A cost of 10 means 210 = 1,024 internal iterations. Increasing this by 1 doubles the computation time. The next 22 characters are the salt. The rest is the hash itself.
4. Encryption vs Hashing
People often use “encryption” and “hashing” interchangeably. They are fundamentally different operations with fundamentally different purposes. Understanding the difference is essential.
Hashing: One-Way
Hashing transforms data into a fixed-size fingerprint that cannot be reversed. You cannot get the original data back from a hash. This is perfect for passwords: you do not need to know the password, you only need to verify it. Hashing is a one-way street with no return trip.
Encryption: Two-Way
Encryption transforms data into an unreadable form that can be reversed with the correct key. This is necessary when you need the original data back — for example, sending a private message, storing a credit card number (encrypted at rest), or transmitting data over HTTPS. Encryption is a round trip: encrypt with a key, decrypt with a key.
Think of it this way. Hashing is like putting a document through a paper shredder. You can verify that a specific document would produce those exact shreds, but you can never reassemble the original from the shreds alone. Encryption is like putting a document in a locked safe. Anyone with the key can open the safe and read the document. Without the key, the contents are inaccessible.
There are two types of encryption: symmetric and asymmetric.
Symmetric Encryption (AES)
In symmetric encryption, the same key is used to both encrypt and decrypt. Think of it like a house key — the same key that locks the door also unlocks it. The most widely used symmetric algorithm is AES (Advanced Encryption Standard). AES is extremely fast and is used everywhere: disk encryption, VPNs, file encryption, and the bulk data transfer phase of HTTPS.
The challenge with symmetric encryption is key distribution: how do you securely share the secret key with the other person? If you send the key over an insecure channel, anyone who intercepts it can decrypt all your messages. This is where asymmetric encryption comes in — but first, let us see AES in action:
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import java.security.SecureRandom;
import java.util.Base64;
public class AESDemo {
public static void main(String[] args) throws Exception {
// Generate a random 256-bit AES key
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256);
SecretKey secretKey = keyGen.generateKey();
// Generate a random initialization vector (IV)
byte[] ivBytes = new byte[16];
new SecureRandom().nextBytes(ivBytes);
IvParameterSpec iv = new IvParameterSpec(ivBytes);
String originalMessage = "This is a secret message that only the key holder can read.";
// Encrypt
Cipher encryptCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
encryptCipher.init(Cipher.ENCRYPT_MODE, secretKey, iv);
byte[] encryptedBytes = encryptCipher.doFinal(originalMessage.getBytes("UTF-8"));
String encryptedBase64 = Base64.getEncoder().encodeToString(encryptedBytes);
System.out.println("Original: " + originalMessage);
System.out.println("Encrypted: " + encryptedBase64);
System.out.println();
// Decrypt (using the SAME key and IV)
Cipher decryptCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
decryptCipher.init(Cipher.DECRYPT_MODE, secretKey, iv);
byte[] decryptedBytes = decryptCipher.doFinal(encryptedBytes);
String decryptedMessage = new String(decryptedBytes, "UTF-8");
System.out.println("Decrypted: " + decryptedMessage);
System.out.println("Match? " + originalMessage.equals(decryptedMessage));
}
}
Let us walk through this carefully. KeyGenerator.getInstance("AES") creates a generator for AES keys. keyGen.init(256) specifies a 256-bit key (the strongest standard AES key size). keyGen.generateKey() generates a random secret key.
The initialization vector (IV) is a random value that ensures the same plaintext encrypted with the same key produces different ciphertext each time. Without an IV, encrypting the same message twice would produce identical ciphertext, which leaks information. The IV does not need to be secret — it is typically stored alongside the ciphertext.
"AES/CBC/PKCS5Padding" specifies three things: the algorithm (AES), the mode (CBC — Cipher Block Chaining), and the padding scheme (PKCS5). CBC mode chains blocks together so that each block’s encryption depends on the previous block, which prevents pattern detection. PKCS5Padding adds bytes to the end of the message so it aligns to the block size (16 bytes for AES).
The encrypted output is raw bytes, so we encode it as Base64 for display. When decrypting, we use the exact same key and IV. If either is wrong, decryption fails.
Asymmetric Encryption (RSA)
Asymmetric encryption uses two different keys: a public key and a private key. They are mathematically linked but you cannot derive the private key from the public key. Data encrypted with the public key can only be decrypted with the private key, and vice versa.
The best analogy is a mailbox. Anyone can walk up to your mailbox and drop a letter through the slot (encrypt with your public key). But only you have the key to open the mailbox and read the letters (decrypt with your private key). You can share your public key with the entire world. Only you keep the private key secret.
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.PublicKey;
import javax.crypto.Cipher;
import java.util.Base64;
public class RSADemo {
public static void main(String[] args) throws Exception {
// Generate an RSA key pair (public + private)
KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA");
keyPairGen.initialize(2048);
KeyPair keyPair = keyPairGen.generateKeyPair();
PublicKey publicKey = keyPair.getPublic();
PrivateKey privateKey = keyPair.getPrivate();
String originalMessage = "Hello, this message is encrypted with RSA!";
// Encrypt with the PUBLIC key
Cipher encryptCipher = Cipher.getInstance("RSA");
encryptCipher.init(Cipher.ENCRYPT_MODE, publicKey);
byte[] encryptedBytes = encryptCipher.doFinal(originalMessage.getBytes("UTF-8"));
String encryptedBase64 = Base64.getEncoder().encodeToString(encryptedBytes);
System.out.println("Original: " + originalMessage);
System.out.println("Encrypted: " + encryptedBase64);
System.out.println();
// Decrypt with the PRIVATE key
Cipher decryptCipher = Cipher.getInstance("RSA");
decryptCipher.init(Cipher.DECRYPT_MODE, privateKey);
byte[] decryptedBytes = decryptCipher.doFinal(encryptedBytes);
String decryptedMessage = new String(decryptedBytes, "UTF-8");
System.out.println("Decrypted: " + decryptedMessage);
System.out.println("Match? " + originalMessage.equals(decryptedMessage));
System.out.println();
// Show key sizes
System.out.println("Public key size: " +
Base64.getEncoder().encodeToString(publicKey.getEncoded()).length() + " chars (Base64)");
System.out.println("Private key size: " +
Base64.getEncoder().encodeToString(privateKey.getEncoded()).length() + " chars (Base64)");
}
}
KeyPairGenerator.getInstance("RSA") creates an RSA key pair generator. keyPairGen.initialize(2048) specifies 2048-bit keys, which is the current minimum recommended size for RSA. The generator produces both a public key and a private key simultaneously.
Notice the critical difference from AES: we encrypt with the public key and decrypt with the private key. Anyone who has the public key can encrypt a message, but only the holder of the private key can read it.
RSA has a significant limitation: it is much slower than AES and can only encrypt small amounts of data (for 2048-bit RSA, the maximum is 245 bytes). This is why RSA is not used to encrypt entire messages or files directly. Instead, it is used to encrypt a symmetric key, which is then used to encrypt the actual data. This hybrid approach gives you the key-distribution advantages of asymmetric encryption with the speed of symmetric encryption. This is exactly how HTTPS works, as we will see in the next section.
| Feature | AES (Symmetric) | RSA (Asymmetric) |
|---|---|---|
| Keys | One shared secret key | Public key + private key |
| Speed | Very fast | Slow (100–1000x slower) |
| Data size | Unlimited | Very small (245 bytes for 2048-bit) |
| Use case | Encrypting files, disk, bulk data | Key exchange, digital signatures |
| Key problem | How to share the key securely | Too slow for large data |
5. HTTPS Explained
Every time you visit a website that starts with https://, something extraordinary is happening behind the scenes. Your browser and the server are performing a cryptographic handshake that uses both asymmetric and symmetric encryption to create a secure channel. Let us walk through exactly what happens when you type https://google.com into your browser.
The TLS Handshake
TLS (Transport Layer Security) is the protocol that powers HTTPS. Here is the handshake, step by step:
Step 1: Client Hello
Your browser sends a “Client Hello” message to Google’s server. This message includes: the TLS version your browser supports, a list of cipher suites (combinations of encryption algorithms) your browser can use, and a random number generated by your browser (called the client random).
Step 2: Server Hello + Certificate
Google’s server responds with a “Server Hello” that includes: the cipher suite it chose from your list, a random number generated by the server (the server random), and Google’s TLS certificate. The certificate contains Google’s public key and is digitally signed by a Certificate Authority (CA) like DigiCert or Let’s Encrypt. Your browser checks this signature to verify that the certificate really belongs to Google and has not been tampered with.
Step 3: Key Exchange (Asymmetric)
Your browser generates a pre-master secret — a random value that will be used to derive the encryption keys for this session. Your browser encrypts the pre-master secret using Google’s public key from the certificate. Only Google’s server, which holds the corresponding private key, can decrypt it. Your browser sends this encrypted pre-master secret to the server.
Step 4: Session Keys (Symmetric)
Both the browser and the server now have three values: the client random, the server random, and the pre-master secret. Both sides independently use these three values to derive the same session key — a symmetric AES key. From this point forward, all communication is encrypted with this AES session key. The asymmetric cryptography has served its purpose: it allowed two strangers to agree on a shared secret without anyone else being able to learn it.
Step 5: Secure Communication
Everything you send to Google and everything Google sends back is now encrypted with AES using the session key. Your search queries, your email contents, your login credentials — all encrypted. When the session ends, the session key is discarded. A new session means a new handshake and new keys.
This design is elegant because it uses each type of encryption where it excels. RSA (asymmetric) solves the key distribution problem — your browser can securely send a secret to a server it has never communicated with before. AES (symmetric) handles the actual data encryption at high speed. The best of both worlds.
What Happens Without HTTPS
On plain HTTP (without the S), nothing is encrypted. Every single byte travels across the network as readable text. This means:
- Anyone on the same Wi-Fi network (a coffee shop, an airport, a library) can read every page you visit, every form you submit, every password you type.
- Your Internet Service Provider (ISP) can see and log every URL you visit and every piece of data you send.
- Attackers can perform man-in-the-middle attacks, intercepting your traffic, reading it, modifying it, and forwarding it — and you would never know.
6. Hands-On Challenges
Theory only becomes knowledge when you put it into practice. This section has three challenges of increasing difficulty. Work through each one carefully. These are not puzzles with trick answers — they are realistic scenarios that use everything you have learned.
Challenge 1: Crack These SHA-256 Hashes
Below are five SHA-256 hashes. Each one was produced by hashing a common password. Your job is to write a Java program that takes a list of common passwords, hashes each one with SHA-256, and checks if any of the hashes match the targets. This is exactly how rainbow table attacks work — you are playing the role of the attacker to understand the threat.
Here are the target hashes to crack:
Hash 1: 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8
Hash 2: 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
Hash 3: 65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5
Hash 4: 8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414
Hash 5: b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb7
Write a program that hashes common passwords and identifies which password produced each hash. Use the sha256() method from Part 2. Start with passwords like: password, 123456, qwerty, letmein, password1, abc123, monkey, dragon, iloveyou, trustno1, 123456789, baseball, shadow. Add more if needed.
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
public class HashCracker {
public static void main(String[] args) throws NoSuchAlgorithmException {
// Target hashes to crack
Map<String, String> targets = new HashMap<>();
targets.put("5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8", "Hash 1");
targets.put("8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92", "Hash 2");
targets.put("65e84be33532fb784c48129675f9eff3a682b27168c0ea744b2cf58ee02337c5", "Hash 3");
targets.put("8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414", "Hash 4");
targets.put("b109f3bbbc244eb82441917ed06d618b9008dd09b3befd1b5e07394c706a8bb7", "Hash 5");
// Common passwords to try
String[] commonPasswords = {
"password", "123456", "qwerty", "letmein", "password1",
"abc123", "monkey", "dragon", "iloveyou", "trustno1",
"123456789", "baseball", "shadow", "master", "michael",
"football", "654321", "welcome", "hello", "charlie"
// TODO: Add more common passwords to crack all five hashes!
};
System.out.println("Attempting to crack " + targets.size() + " hashes...");
System.out.println();
int cracked = 0;
for (String password : commonPasswords) {
String hash = sha256(password);
if (targets.containsKey(hash)) {
System.out.println("CRACKED " + targets.get(hash) + "!");
System.out.println(" Password: " + password);
System.out.println(" Hash: " + hash);
System.out.println();
cracked++;
}
}
System.out.println("Cracked " + cracked + " out of " + targets.size() + " hashes.");
if (cracked < targets.size()) {
System.out.println("Try adding more common passwords to crack the rest!");
}
}
public static String sha256(String input) throws NoSuchAlgorithmException {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hashBytes = digest.digest(input.getBytes());
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
}
}
This challenge demonstrates exactly why unsalted SHA-256 is terrible for password storage. You just cracked passwords by doing exactly what an attacker does: hash a list of guesses and compare. With a GPU, an attacker could try billions of guesses per second. Every cracked hash in your output is a user whose account is compromised. This is why salting and bcrypt exist.
Challenge 2: AES Encrypt/Decrypt Exchange
In this challenge you will build a complete AES encryption utility that can encrypt a message, display the encrypted form, and then decrypt it back. Think of this as building the “lock and unlock” mechanism for a secure messaging system. The program should accept user input, encrypt it, and then decrypt the result to prove the round trip works.
import javax.crypto.Cipher;
import javax.crypto.KeyGenerator;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.SecureRandom;
import java.util.Base64;
public class AESExchange {
// Generate a new random AES-256 key
public static SecretKey generateKey() throws Exception {
KeyGenerator keyGen = KeyGenerator.getInstance("AES");
keyGen.init(256);
return keyGen.generateKey();
}
// Encrypt a plaintext message. Returns "iv:ciphertext" in Base64.
public static String encrypt(String plaintext, SecretKey key) throws Exception {
byte[] ivBytes = new byte[16];
new SecureRandom().nextBytes(ivBytes);
IvParameterSpec iv = new IvParameterSpec(ivBytes);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, key, iv);
byte[] encrypted = cipher.doFinal(plaintext.getBytes("UTF-8"));
// Combine IV and ciphertext so the recipient has everything needed to decrypt
String ivBase64 = Base64.getEncoder().encodeToString(ivBytes);
String cipherBase64 = Base64.getEncoder().encodeToString(encrypted);
return ivBase64 + ":" + cipherBase64;
}
// Decrypt a message in "iv:ciphertext" Base64 format
public static String decrypt(String encryptedPackage, SecretKey key) throws Exception {
String[] parts = encryptedPackage.split(":");
byte[] ivBytes = Base64.getDecoder().decode(parts[0]);
byte[] cipherBytes = Base64.getDecoder().decode(parts[1]);
IvParameterSpec iv = new IvParameterSpec(ivBytes);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, key, iv);
byte[] decrypted = cipher.doFinal(cipherBytes);
return new String(decrypted, "UTF-8");
}
public static void main(String[] args) throws Exception {
SecretKey sharedKey = generateKey();
// Simulate an exchange of encrypted messages
String[] messages = {
"Meet me at the library at 3pm.",
"The project deadline is next Friday.",
"Remember: never store passwords in plain text!"
};
System.out.println("=== AES-256 Encrypted Message Exchange ===");
System.out.println("Key (Base64): " +
Base64.getEncoder().encodeToString(sharedKey.getEncoded()));
System.out.println();
for (String message : messages) {
String encrypted = encrypt(message, sharedKey);
String decrypted = decrypt(encrypted, sharedKey);
System.out.println("Original: " + message);
System.out.println("Encrypted: " + encrypted);
System.out.println("Decrypted: " + decrypted);
System.out.println("Valid: " + message.equals(decrypted));
System.out.println();
}
// Demonstrate that a wrong key cannot decrypt
SecretKey wrongKey = generateKey();
String encrypted = encrypt("Secret message", sharedKey);
System.out.println("--- Attempting decryption with wrong key ---");
try {
String result = decrypt(encrypted, wrongKey);
System.out.println("Decrypted (wrong key): " + result);
} catch (Exception e) {
System.out.println("FAILED: " + e.getClass().getSimpleName());
System.out.println("The wrong key cannot decrypt the message. Security works!");
}
}
}
This program packages the IV with the ciphertext (separated by a colon) so that the recipient has everything they need to decrypt. In a real messaging system, the IV and ciphertext would be sent together over the network. The key would have been shared separately through a secure channel (or, more commonly, through an RSA key exchange as in HTTPS).
Challenge 3: BCrypt Password Registration and Login System
This is the capstone challenge. You are going to build a simple but complete password system that registers users with bcrypt-hashed passwords and verifies them at login. This is the core of how every modern web application handles authentication.
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import java.util.HashMap;
import java.util.Map;
public class PasswordSystem {
// Simulated database: maps username to bcrypt hash
private static Map<String, String> userDatabase = new HashMap<>();
private static BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(12);
// Register a new user
public static boolean register(String username, String password) {
// Check if username already exists
if (userDatabase.containsKey(username)) {
System.out.println(" ERROR: Username '" + username + "' already exists.");
return false;
}
// Validate password strength (basic check)
if (password.length() < 8) {
System.out.println(" ERROR: Password must be at least 8 characters.");
return false;
}
// Hash the password with bcrypt and store it
String hashedPassword = encoder.encode(password);
userDatabase.put(username, hashedPassword);
System.out.println(" User '" + username + "' registered successfully.");
System.out.println(" Stored hash: " + hashedPassword);
return true;
}
// Attempt to log in
public static boolean login(String username, String password) {
// Check if user exists
if (!userDatabase.containsKey(username)) {
System.out.println(" ERROR: Invalid username or password.");
return false;
}
// Retrieve the stored hash and verify the password
String storedHash = userDatabase.get(username);
boolean matches = encoder.matches(password, storedHash);
if (matches) {
System.out.println(" Login successful! Welcome back, " + username + ".");
} else {
System.out.println(" ERROR: Invalid username or password.");
}
return matches;
}
public static void main(String[] args) {
System.out.println("=== BCrypt Password Registration & Login System ===");
System.out.println();
// Register users
System.out.println("--- Registration ---");
register("alice", "securePassword99");
System.out.println();
register("bob", "myStr0ngP@ssword");
System.out.println();
register("carol", "iLoveJava2025!");
System.out.println();
// Try registering a duplicate user
System.out.println("--- Duplicate Registration Attempt ---");
register("alice", "differentPassword");
System.out.println();
// Try registering with a weak password
System.out.println("--- Weak Password Attempt ---");
register("dave", "short");
System.out.println();
// Successful logins
System.out.println("--- Login Attempts ---");
login("alice", "securePassword99");
System.out.println();
login("bob", "myStr0ngP@ssword");
System.out.println();
// Failed logins
System.out.println("--- Failed Login Attempts ---");
login("alice", "wrongPassword");
System.out.println();
login("alice", "securePassword98"); // Off by one character
System.out.println();
login("eve", "hacker123"); // Non-existent user
System.out.println();
// Show that the database never contains plain text passwords
System.out.println("--- Database Contents (what an attacker would see) ---");
for (Map.Entry<String, String> entry : userDatabase.entrySet()) {
System.out.println(" " + entry.getKey() + " -> " + entry.getValue());
}
System.out.println();
System.out.println("Notice: No plain text passwords anywhere. Even if this");
System.out.println("database is stolen, the attacker gets only bcrypt hashes.");
System.out.println("Each hash has a unique salt, so rainbow tables are useless.");
}
}
Study this program carefully. It demonstrates every principle from this lesson working together:
- No plain text passwords are stored. The
userDatabasemap only contains bcrypt hashes. - Each hash is unique. Even if two users chose the same password, their hashes would be different because bcrypt generates a random salt each time.
- Verification works without knowing the password. The
matches()method extracts the salt from the stored hash, re-hashes the input, and compares. The original password is never stored or reconstructed. - Wrong passwords fail reliably. Even
"securePassword98"(off by one character from the correct password) produces a completely different hash and fails verification. - Error messages are generic. The login method says “Invalid username or password” for both wrong usernames and wrong passwords. This is a security best practice — specific error messages like “Username not found” or “Wrong password” tell an attacker which half of the credential is correct.
- The cost factor is set to 12.
new BCryptPasswordEncoder(12)means 212 = 4,096 internal hashing iterations. This makes each hash take roughly 200–300 milliseconds — imperceptible to a user logging in, but devastating to an attacker trying millions of guesses.
7. Security in the Real World
Everything you have learned in this lab forms the foundation of real-world application security. Let us connect these concepts to the technologies and practices you will encounter as a professional developer.
JWT Tokens
JSON Web Tokens (JWTs) are how most modern web applications handle authentication after login. When a user logs in successfully (their bcrypt-hashed password is verified), the server creates a JWT — a signed token that contains the user’s identity information. The token is signed using HMAC (a hash-based message authentication code) or RSA, so the server can verify that it was not tampered with.
The token structure is header.payload.signature, where each part is Base64-encoded. The signature is a hash (or RSA signature) of the header and payload using a secret key. If anyone modifies the payload (for example, changing their user role from “user” to “admin”), the signature will not match and the server will reject the token. This is hashing being used for integrity verification — another critical application beyond password storage.
Key Management
In the AES and RSA examples above, we generated keys in our program. In production, key management is one of the hardest problems in security. Keys need to be stored securely (not in your source code, not in a config file committed to Git). Companies use dedicated services like AWS Key Management Service (KMS), HashiCorp Vault, or Azure Key Vault to generate, store, rotate, and audit access to encryption keys. A leaked encryption key is just as catastrophic as a leaked password — it renders all the encryption meaningless.
The Golden Rule: Never Implement Your Own Crypto
This is the single most important security principle for software developers. Never implement your own cryptographic algorithms. The code in this lesson uses Java’s built-in MessageDigest, Cipher, and Spring Security’s BCryptPasswordEncoder. These are battle-tested implementations written by cryptography experts and reviewed by thousands of security researchers over decades.
Even tiny implementation mistakes — a timing side-channel, a biased random number generator, an incorrect padding scheme — can completely break the security of a system. Cryptography is one of the few areas in software engineering where “it works” does not mean “it is correct.” A broken crypto implementation will produce outputs that look perfectly valid, pass all your tests, and appear to work flawlessly — while being trivially breakable by anyone who knows what to look for.
Use established libraries. Use them correctly. Let the experts handle the math.
Further Reading
- Crypto 101 (crypto101.io) — A free, comprehensive introduction to cryptography. It covers everything from basic concepts to advanced protocols, written for programmers rather than mathematicians. Start here if you want to go deeper.
- Computerphile (YouTube) — The channel has excellent videos on hashing, encryption, public key cryptography, and HTTPS. Search for “Computerphile hashing,” “Computerphile AES,” or “Computerphile public key cryptography.” The explanations are visual and approachable.
- OWASP Password Storage Cheat Sheet — The Open Web Application Security Project maintains a definitive guide on how to store passwords correctly. It covers algorithm selection, cost factors, migration strategies, and common mistakes.
- The Spring Security documentation — If you are building Java web applications, Spring Security provides battle-tested implementations for password hashing, authentication, authorization, CSRF protection, and more.
Quiz
1. A company stores user passwords by hashing them with SHA-256 (no salt). An attacker steals the database. Which statement best describes the attacker’s situation?
2. In the HTTPS/TLS handshake, why does the browser use asymmetric encryption (RSA) to exchange a key and then switch to symmetric encryption (AES) for the rest of the communication?
3. You hash the password “hunter2” twice using Spring Security’s BCryptPasswordEncoder. The two resulting hashes are completely different strings. Why?
encode() is called. The salt is embedded directly in the resulting hash string (the 22 characters after the cost factor). When you call matches(password, storedHash), bcrypt extracts the salt from the stored hash, re-hashes the password with that specific salt, and compares the result. This is why two hashes of the same password look different but both verify correctly. This design defeats rainbow tables because an attacker would need a separate rainbow table for every possible salt value, which is computationally impossible.Deliverable
What You Built
Working Java programs demonstrating SHA-256 hashing with MessageDigest, the avalanche effect, salting concepts, bcrypt password hashing with BCryptPasswordEncoder, AES-256 symmetric encryption and decryption, RSA asymmetric encryption and decryption, and a complete bcrypt-based password registration and login system with duplicate detection, password validation, and secure error messages. You also gained a thorough understanding of how HTTPS uses both asymmetric and symmetric cryptography to secure web communication.
Finished this side quest?