Crypto from the stdlib, used correctly: argon2 for passwords, constant-time compares, and the GCM nonce invariant
Passwords get argon2id or bcrypt tuned to ~100ms, never bare sha256. Secrets compare with subtle.ConstantTimeCompare because early-exit equality leaks prefix length. Keys come from crypto/rand; AES-GCM lives or dies on nonce uniqueness — reuse leaks the auth key.
The breach notification went out eight days after the database dump surfaced on a leak forum. The team had felt safe: API keys were never stored in plaintext — every key was run through sha256 before it touched the keys table. Then a customer reported charges made with a key that was supposedly protected. The post-mortem reconstructed the attack in one afternoon. The keys were 28 characters: a fixed product prefix plus a short suffix generated by math/rand seeded once at process start — about 40 bits of real entropy. The attacker enumerated that space against the dumped digests at GPU speed (sha256 runs at roughly ten billion guesses per second on a single card) and recovered most active keys overnight. Two one-line fixes would each have killed the attack: generate keys from crypto/rand with 256 bits of entropy, or hash the low-entropy ones with argon2id so each guess costs 100 milliseconds instead of 100 picoseconds. The team had neither — because sha256 felt like hashing.
Route every job to its primitive
When you see a secret-handling task in code review — password storage, token generation, comparison — the question is not “is this crypto correct?” but “is this the approved primitive for this job?” That framing is what this lesson trains.
Senior crypto discipline in Go is not “know cryptography” — it is “refuse to invent it”. The approved surface is the stdlib crypto/* tree plus golang.org/x/crypto, which is maintained by the same team to the same bar. Every secret-handling job in a service maps to exactly one of a handful of answers, and the engineering work is routing, not designing: password storage, token generation, secret comparison, encryption at rest, TLS. The moment a design needs a primitive composed in a way no stdlib API offers directly — encrypting with a hand-built mode, deriving keys with an ad-hoc sha256 chain — that is the signal to stop and get a design review, because misuse at this layer is silent: the code works, the tests pass, and the failure only appears in someone else’s write-up.
Passwords: cost is the feature
sha256 and sha512 are designed to be fast — that is the whole point for signatures and integrity checks, and the fatal flaw for passwords. One modern GPU computes on the order of 10–20 billion SHA-256 hashes per second; an eight-character human password falls in minutes, salted or not (a salt defeats precomputed tables, it does nothing about per-target brute force). Password hashing functions invert the goal: they are deliberately expensive. The stdlib answer lives in x/crypto — bcrypt, scrypt, and argon2, with argon2id the current default recommendation (RFC 9106 suggests 64 MiB of memory, 1 pass, parallelism 4):
import "golang.org/x/crypto/argon2"
func hashPassword(password, salt []byte) []byte {
// RFC 9106 first recommendation: 1 pass, 64 MiB, 4 lanes, 32-byte key.
// Tune until one call costs ~100ms-1s on production hardware.
return argon2.IDKey(password, salt, 1, 64*1024, 4, 32)
}The honest tuning rule: target roughly 100 ms to 1 s per hash on your real servers, then do the memory math. The memory cost is a feature against GPUs (64 MiB per guess starves their parallelism) and a trap for you: 50 concurrent logins at 64 MiB each is 3.2 GB on one pod. Production answers are a login queue or rate limit, not lowering memory until the attacker is comfortable again. The same trap has a runtime-specific shape elsewhere — Node’s crypto.scrypt enforces a 32 MiB maxmem default and rejects bigger parameters — but Go’s x/crypto/scrypt has no guard at all: it allocates 128 * N * r bytes per call and will happily let an unthrottled login endpoint OOM your own service. Store the parameters next to the hash (the standard encoded formats do this) and re-hash on successful login when parameters age.
A teammate proposes storing user passwords as sha256(salt + password), arguing the salt defeats rainbow tables. What is the actual remaining weakness?
Compare secrets in constant time
Before you reach for == on a token or API key, ask yourself: could someone measure how long that comparison takes? With enough samples, they can.
A plain == on strings or bytes.Equal exits at the first differing byte. That makes comparison time proportional to the length of the matched prefix — a measurable signal. The attack is concrete: against a token check, an attacker fixes the first byte, tries all 256 values, and keeps the one with the slowest median response; then the second byte, and so on. A 32-byte secret falls in roughly 256 * 32 guided guesses per position-sweep instead of 2^256 — thousands of requests, not lifetimes. Network jitter does not save you; it averages out over a few thousand samples per guess on a LAN or between co-located services.
import (
"crypto/sha256"
"crypto/subtle"
)
func tokenEqual(presented, stored []byte) bool {
// Hash both sides first: inputs become equal-length, so the
// length-mismatch early exit in ConstantTimeCompare leaks nothing
// about the secret itself.
hp, hs := sha256.Sum256(presented), sha256.Sum256(stored)
return subtle.ConstantTimeCompare(hp[:], hs[:]) == 1
}subtle.ConstantTimeCompare examines every byte regardless of where mismatches occur — but it returns 0 immediately when lengths differ, which leaks the secret’s length. Hashing both sides first, as above, closes that and is the standard idiom for MACs, API tokens, and webhook signatures.
▸Why this works
Why treat a sub-microsecond timing difference as exploitable? Because the attacker controls the sample count. A 0.5 µs per-byte difference is invisible in one request and unambiguous in the median of 50,000 — and internal services, retries, and HTTP/2 multiplexing give attackers exactly that volume. Remote timing attacks recovering keys across networks have been demonstrated since the early 2000s. The defense costs one function call, so the honest bar for skipping it is “never”: you cannot prove your deployment topology will stay noisy forever.
Randomness: crypto/rand for anything an attacker must not guess
math/rand v1 is a seedable, deterministic generator — recover the seed (often a timestamp) and you recover every token it ever produced, which is exactly the Hook’s breach. Go 1.22 quietly improved the floor: math/rand and math/rand/v2 now run on ChaCha8 seeded from OS entropy, so accidentally using them is no longer instantly fatal. The nuance to keep honest: that is a hardening of the default, not a contract — the package still documents itself as unsuitable for security work, and explicit seeding reintroduces full predictability. The rule stays one sentence: keys, tokens, session IDs, salts, and nonces come from crypto/rand, period. Since Go 1.24, crypto/rand.Read is documented never to fail (it crashes the program rather than return weak bytes), and rand.Text() gives you a ready-made random token string.
AES-GCM: nonce uniqueness is the invariant
GCM is CTR-mode encryption plus a GHASH authenticator, and both halves depend on one invariant: a (key, nonce) pair is used for exactly one message. Reuse a nonce and the same keystream encrypts two messages — XOR of the ciphertexts equals XOR of the plaintexts, which against structured data (JSON bodies, fixed headers) usually yields both. Worse, nonce reuse exposes the GHASH authentication key — the classic “forbidden attack” — after which the attacker forges ciphertexts your service authenticates and accepts. This is the catastrophic class: not “one message weakened” but key-level compromise. The safe pattern is mechanical:
func encrypt(key, plaintext []byte) ([]byte, error) {
block, err := aes.NewCipher(key) // 32-byte key -> AES-256
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize()) // 12 bytes
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
// Seal appends to its first arg: output is nonce || ciphertext || tag,
// so decrypt can split the nonce back off the front.
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}Random 12-byte nonces are safe up to a birthday bound: NIST guidance caps a single key at 2^32 messages with random nonces. For most services that is comfortably unreachable; for high-volume pipelines it is a key-rotation requirement you write down, not a detail you discover.
A service encrypts messages with AES-GCM but reuses the same nonce for every message under one key. What does an attacker who captures traffic actually gain?
TLS: do nothing, then verify you did nothing
Go’s TLS defaults are good: modern minimum versions, sane cipher suites, full certificate verification. The production failure mode is not weak defaults but humans lowering them — InsecureSkipVerify: true added “temporarily” for a staging self-signed cert, shipped to prod, where it silently disables the entire point of TLS. The cheap, effective control is the grep test: CI fails on any InsecureSkipVerify: true outside test files. The legitimate need it usually masks — trusting an internal CA — has a correct answer: add the CA to RootCAs in the client config. The same skepticism applies to copy-pasted CipherSuites and MinVersion blocks from decade-old blog posts: deleting them is usually the security upgrade.
- 01Explain the timing-attack mechanism against a plain == token comparison, and the complete stdlib fix.
- 02State the AES-GCM nonce invariant, what breaks when it is violated, and the safe production pattern including its limit.
The discipline of this lesson is refusal: every secret-handling job routes to a stdlib or x/crypto primitive used exactly as documented, and anything that requires composing primitives by hand is a design-review trigger, not a coding task. Passwords are the clearest case — sha256 is fast by design, GPUs guess at tens of billions per second, and a salt only separates targets — so storage means argon2id (or bcrypt) tuned to roughly 100ms–1s per hash, with the memory math done honestly: 64 MiB per concurrent login multiplies into gigabytes under a burst, which you answer with rate limits, not weaker parameters. Secret comparison never uses == or bytes.Equal, because early exit makes time proportional to the matched prefix and a few thousand samples per guess turn that into byte-by-byte recovery; subtle.ConstantTimeCompare over sha256 digests of both sides closes both the timing channel and the length leak. Randomness for anything adversarial — keys, tokens, salts, nonces — comes from crypto/rand even though Go 1.22 upgraded math/rand to ChaCha8; the upgrade is a hardened default, not a security contract. AES-GCM holds confidentiality and authenticity only while each (key, nonce) pair encrypts one message: reuse leaks plaintext XORs and the GHASH key, enabling forgery, so the pattern is a fresh random 12-byte nonce prepended to each ciphertext and key rotation before the 2^32-message birthday budget. TLS needs nothing from you except restraint — keep the defaults, grep CI for InsecureSkipVerify, and fix internal-CA trust with RootCAs instead of turning verification off. Now when you see a sha256 hash on a password field, or a bare == on a token, or a nonce drawn from a counter rather than crypto/rand, you know exactly which primitive to reach for and why the wrong one is already an incident waiting to be written.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.