skip to content
everything.io.securityAndCrypto

security and crypto

hashing, signing, encryption, and a TLS handshake, step by step.

chapter
chapter 10.10
reading time
9 min read

five building blocks, all in the JDK: a hash fingerprints data, a MAC is a hash only key-holders can make, a cipher hides data (AES with one shared key, or public/private key pairs), and a signature proves who wrote something. TLS glues them together into a secure connection.

the rules: use the standard algorithms through java.security and javax.crypto, use SecureRandom, hash passwords slowly, and never invent your own.

the picture

you know how a wax seal on a letter tells you nobody opened it, but doesn't hide what's inside, while a locked box hides the letter but doesn't tell you who sent it? crypto is a toolbox of exactly those separate guarantees, and most mistakes come from using one where you needed another.

you needprimitivein javakeys
detect any change to datahashMessageDigest (SHA-256, SHA3-256)none
detect changes and know it came from a key holderMACMac (HmacSHA256)one shared secret
hide datasymmetric cipherCipher (AES/GCM)one shared secret
agree on a secret over an open wirekey exchange / KEMKeyAgreement (X25519), KEM (ML-KEM)a key pair each
prove who wrote it, to anyonesignatureSignature (Ed25519, ML-DSA)private signs, public verifies
turn a password into a stored hashslow KDFSecretKeyFactory (PBKDF2)a salt

hashes: fingerprints

a hash turns any input into a fixed-size digest (32 bytes for SHA-256). the same input always gives the same digest; you can't go backwards; and you can't find two inputs with the same digest. the property you can see is the avalanche: change one character and roughly half the output bits flip.

a =
b =
sha256(a) 75b79cfda6fb32768bca896cfc3fcff96e6dd92c04cbbe894a28774d7902bfab
sha256(b) 7654b974bec8906ba6edcfa6aab07bdf8ac47e1b5a4101f06b25dab834bafb41
input chars changed = 1digest bits changed = 126 / 256

about half the bits flip (126 of 256), and you can't predict which. a tiny change in the input looks like a completely unrelated digest: that's the avalanche effect.

edit either input. both digests are real SHA-256, identical to what MessageDigest gives in java (the default pair was checked on jdk 25). each square is one of the 256 output bits; amber ones differ between the two.
Main.java
import java.security.MessageDigest;
import java.util.HexFormat;
 
void main() throws Exception {
    var sha = MessageDigest.getInstance("SHA-256");
    byte[] a = sha.digest("transfer 100 to bob".getBytes());
    byte[] b = sha.digest("transfer 900 to bob".getBytes());
    IO.println(HexFormat.of().formatHex(a));
    IO.println(HexFormat.of().formatHex(b));
    int diff = 0;
    for (int i = 0; i < a.length; i++) diff += Integer.bitCount((a[i] ^ b[i]) & 0xff);
    IO.println(diff + " of 256 bits differ");
}
output, from jdk 25
75b79cfda6fb32768bca896cfc3fcff96e6dd92c04cbbe894a28774d7902bfab
7654b974bec8906ba6edcfa6aab07bdf8ac47e1b5a4101f06b25dab834bafb41
126 of 256 bits differ

this one needs a real JVM (a library class the browser runtime doesn't have), so it doesn't run in the browser. copy it into java Main.java to try it.

a hash alone proves nothing about who: an attacker who changes the message just recomputes the hash. add a secret key and you get a MAC, which only key holders can produce. that's how signed cookies, webhook signatures and API request signing work:

Main.java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
 
void main() throws Exception {
    byte[] key = "a key only the server knows".getBytes();
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(key, "HmacSHA256"));
    byte[] tag = mac.doFinal("user=ada;role=reader".getBytes());
 
    byte[] forged = mac.doFinal("user=ada;role=admin".getBytes());
    IO.println("tag still valid after edit: " + MessageDigest.isEqual(tag, forged));
}
output
tag still valid after edit: false

passwords: slow on purpose

SHA-256 is built to be fast, which is exactly wrong for passwords: a leaked table of fast hashes can be brute-forced with GPUs at billions of guesses per second. password hashing uses a deliberately slow function, a random salt per user (so identical passwords don't hash alike), and a cost you can raise over time.

SHA-256, once49 ns
PBKDF2, 600k iterations56,000,000 ns
log scale
one SHA-256 of a short string versus one PBKDF2-HMAC-SHA256 with 600,000 iterations (OWASP's current recommendation), measured on jdk 25 on an M4 Pro. log scale: the slow one is about a million times slower, which is the whole point.
PasswordHash.java
byte[] salt = new byte[16];
new SecureRandom().nextBytes(salt);                           // a new salt per user, stored next to the hash
 
var spec = new PBEKeySpec(password, salt, 600_000, 256);      // password is a char[], so it can be wiped
byte[] hash = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
    .generateSecret(spec).getEncoded();
spec.clearPassword();

PBKDF2 is the one in the JDK. bcrypt, scrypt and Argon2 (the current favourite, also memory-hard) come from libraries such as Spring Security or Bouncy Castle.

encryption: symmetric and asymmetric

symmetric encryption uses one key to lock and unlock: fast, and the workhorse for bulk data. the mode that matters is AES-GCM: authenticated encryption, meaning it hides the data and detects tampering in one step.

Main.java
import javax.crypto.*;
import javax.crypto.spec.GCMParameterSpec;
import java.security.SecureRandom;
 
void main() throws Exception {
    SecretKey key = KeyGenerator.getInstance("AES").generateKey();      // 256-bit
    byte[] iv = new byte[12];
    new SecureRandom().nextBytes(iv);                                    // never reuse with the same key
 
    Cipher c = Cipher.getInstance("AES/GCM/NoPadding");
    c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv));
    byte[] sealed = c.doFinal("meet at 6".getBytes());
    IO.println("key " + key.getEncoded().length * 8 + " bits, 9 bytes in, " + sealed.length + " bytes out");
 
    c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));
    IO.println(new String(c.doFinal(sealed)));
 
    sealed[0] ^= 1;                                                      // flip one bit in transit
    c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv));
    try {
        c.doFinal(sealed);
    } catch (AEADBadTagException e) {
        IO.println("tampered: " + e.getMessage());
    }
}
output
key 256 bits, 9 bytes in, 25 bytes out
meet at 6
tampered: Tag mismatch

9 bytes in, 25 out: the ciphertext is the same length as the plaintext, plus a 16-byte authentication tag. one flipped bit and decryption refuses to return anything at all.

asymmetric crypto uses a key pair: a public key you can give anyone, a private key you never share. it's slow, so it's used for two small jobs. key agreement: two parties derive the same symmetric key over an open wire. signatures: the private key signs, anyone with the public key verifies.

Main.java
import java.security.*;
 
void main() throws Exception {
    for (String alg : new String[] { "Ed25519", "ML-DSA-65" }) {
        KeyPair pair = KeyPairGenerator.getInstance(alg).generateKeyPair();
        byte[] msg = "release 25.0.4 is mine".getBytes();
 
        Signature s = Signature.getInstance(alg);
        s.initSign(pair.getPrivate());
        s.update(msg);
        byte[] sig = s.sign();
 
        s.initVerify(pair.getPublic());
        s.update(msg);
        boolean ok = s.verify(sig);
 
        msg[0] ^= 1;
        s.initVerify(pair.getPublic());
        s.update(msg);
        boolean tampered = s.verify(sig);
 
        IO.println(alg + ": signature " + sig.length + " bytes, public key " + pair.getPublic().getEncoded().length
            + " bytes, valid " + ok + ", after edit " + tampered);
    }
}
output
Ed25519: signature 64 bytes, public key 44 bytes, valid true, after edit false
ML-DSA-65: signature 3309 bytes, public key 1974 bytes, valid true, after edit false

that second line is new: ML-DSA, a signature scheme built to survive quantum computers, in the JDK since java 24. the sizes are the price, more on that below.

post-quantum, already in your JDK

a large enough quantum computer would break RSA and elliptic curves (Shor's algorithm), the asymmetric half of everything above. nobody has one yet, but traffic recorded today could be decrypted later ("harvest now, decrypt later"), so the replacements are rolling out now. java got them early:

whatJEPjava
KEM APIa standard API for key encapsulation45221
ML-KEMquantum-resistant key encapsulation (FIPS 203)49624
ML-DSAquantum-resistant signatures (FIPS 204)49724
KDF APIkey derivation, HKDF built in51025
hybrid TLS 1.3 key exchangeX25519MLKEM768 on by default52727

a KEM is key exchange shaped differently: one side encapsulates a fresh random secret to the other's public key, the other decapsulates it with the private key. same secret on both ends, nothing secret on the wire:

Main.java
import javax.crypto.KEM;
import java.security.*;
import java.util.Arrays;
 
void main() throws Exception {
    // bob makes a key pair and publishes the public half
    KeyPair bob = KeyPairGenerator.getInstance("ML-KEM-768").generateKeyPair();
 
    // alice: encapsulate a fresh secret to bob's public key
    KEM.Encapsulated enc = KEM.getInstance("ML-KEM").newEncapsulator(bob.getPublic()).encapsulate();
    byte[] sent = enc.encapsulation();                      // what goes over the wire
 
    // bob: open it with his private key
    var bobSecret = KEM.getInstance("ML-KEM").newDecapsulator(bob.getPrivate()).decapsulate(sent);
 
    IO.println("encapsulation: " + sent.length + " bytes on the wire");
    IO.println("shared secret: " + enc.key().getEncoded().length + " bytes");
    IO.println("same secret on both sides: " + Arrays.equals(enc.key().getEncoded(), bobSecret.getEncoded()));
}
output
encapsulation: 1088 bytes on the wire
shared secret: 32 bytes
same secret on both sides: true

the catch is size: 1,088 bytes on the wire instead of x25519's 32, and an ML-DSA signature is 3,309 bytes against Ed25519's 64. that's why TLS uses hybrids (classic and post-quantum together, secure if either holds) and why the switch is gradual.

the secret a KEM or key agreement produces is raw material, not a key. the KDF API java 25+ turns it into keys. this is RFC 5869's first official test vector, and java gets it exactly:

Main.java
import javax.crypto.KDF;
import javax.crypto.spec.HKDFParameterSpec;
import java.util.HexFormat;
 
void main() throws Exception {
    var hex = HexFormat.of();
    // RFC 5869, test case 1
    byte[] ikm = hex.parseHex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
    byte[] salt = hex.parseHex("000102030405060708090a0b0c");
    byte[] info = hex.parseHex("f0f1f2f3f4f5f6f7f8f9");
 
    KDF hkdf = KDF.getInstance("HKDF-SHA256");
    byte[] okm = hkdf.deriveData(HKDFParameterSpec.ofExtract()
        .addIKM(ikm).addSalt(salt)
        .thenExpand(info, 42));
    IO.println(hex.formatHex(okm));
}
output, from jdk 25
3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865

this one needs a real JVM (a library class the browser runtime doesn't have), so it doesn't run in the browser. copy it into java Main.java to try it.

TLS: all of it at once

every https:// call your HttpClient makes runs a TLS handshake first, and it uses every primitive on this page. here's TLS 1.3 the way java 27 does it by default, with the hybrid post-quantum key share:

hashMACKDFkey exchangeAEADsignaturecertificates
clientin the clearserver
  1. ClientHello▶versions, cipher suites, random, key shares
  2. ◀chosen suite + server's key shareServerHello
  3. shared secret → keysshared secret → keys
  4. ◀ALPN (h2?), other settings{EncryptedExtensions}
  5. ◀server's certificate chain{Certificate}
  6. ◀signature over the transcript{CertificateVerify}
  7. ◀MAC over the transcript{Finished}
  8. verify everything
  9. {Finished}▶client's MAC
  10. {application data}▶◀GET / HTTP/2 ...{application data}

the client says what it supports and, betting on what the server will pick, already sends its half of a key exchange: an X25519MLKEM768 share and a plain x25519 one. sending the share up front is why TLS 1.3 needs one round trip, not two.

step 1/10
step through the handshake. the chips on top show which primitives each step uses. blue messages travel in the clear, green ones are already encrypted. from ServerHello on, even the certificate is hidden from eavesdroppers.

in java this all lives in the SunJSSE provider behind SSLSocket, SSLEngine and HttpClient. you rarely touch it directly; the knobs you do touch are the trust store (-Djavax.net.ssl.trustStore), the enabled protocols and named groups (jdk.tls.namedGroups), and -Djavax.net.debug=ssl:handshake when it fails and you need to see why.

one level deeper:SecureRandom, and why Random is a liability+

java.util.Random is a 48-bit linear congruential generator: fast, fine for simulations, and completely predictable. two new Random(42) produce the same sequence, and from a couple of outputs an attacker can recover the internal state and predict every value after. anything an attacker must not guess (keys, IVs, salts, session ids, reset tokens) comes from SecureRandom, which on linux and macOS reads the OS's entropy source (NativePRNG by default on jdk 25). SecureRandom.getInstanceStrong() picks the strongest configured source (NativePRNGBlocking here), which may block waiting for entropy, so it's for long-lived keys, not per-request tokens.

keylines
  • hash = fingerprint, MAC = fingerprint with a key, cipher = hide, signature = prove who.
  • AES/GCM with a fresh IV per message. never ECB, never a reused IV.
  • passwords get slow, salted hashes (PBKDF2, bcrypt, Argon2), never SHA-256 alone.
  • keys, IVs, salts and tokens come from SecureRandom, never Random.
  • post-quantum is here: ML-KEM and ML-DSA (24), KDF API (25), hybrid TLS by default (27).
  • TLS uses all of it. never disable certificate checks to make an error go away.