tinka alpha

Hashing

This guide covers password hashing in Tinka. You will learn how to:

Overview

A password hash turns a password into a string that can't be turned back: you store the hash, and at sign-in you check a password against it. Unlike encryption, hashing is one-way, so a stolen database doesn't reveal your users' passwords. Hashing is deliberately slow and memory-hungry, so guessing passwords by brute force, even on GPUs, costs an attacker more than it's worth.

The password package (github.com/mucolabs/tinka/password) hashes with Argon2id, the winner of the Password Hashing Competition and the algorithm RFC 9106 recommends. Each hash is stored as a PHC string that carries the algorithm and its parameters:

Text
$argon2id$v=19$m=65536,t=3,p=4$Jtxi46WJ26OQ0khsYLLlnw$knXGfuRFsSjXdj88JydPOnUIglvm1S8Wq3nzn7R8vEA

Hash and verify passwords

Create one Hasher when the App starts, and share it: it's safe for concurrent use. Hash returns the string to store, and Verify checks a password against it:

accounts.go
package main

import (
	"context"
	"errors"

	"github.com/mucolabs/tinka/password"
)

type Accounts struct {
	Hasher *password.Hasher
	Users  *UserStore
}

func (a Accounts) Register(ctx context.Context, email, plain string) error {
	hashed, err := a.Hasher.Hash(ctx, plain)
	if err != nil {
		return err
	}
	return a.Users.Create(ctx, email, hashed)
}

var ErrWrongPassword = errors.New("wrong email or password")

func (a Accounts) SignIn(ctx context.Context, email, attempt string) (*User, error) {
	user, err := a.Users.FindByEmail(ctx, email)
	if err != nil {
		return nil, err
	}
	ok, err := a.Hasher.Verify(ctx, user.PasswordHash, attempt)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, ErrWrongPassword
	}
	return user, nil
}

Verify returns false for a wrong password, and an error only when the stored hash is in a format it can't read (password.ErrUnsupported) or the context ended. It compares in constant time, so how long it takes reveals nothing about the password.

Configure the cost

The zero Config uses RFC 9106's second recommended parameters: 64 MiB of memory, 3 passes and 4 lanes, about 85 ms per hash on a current server CPU. Raise them as hardware gets faster:

Memoryuint32

Memory each hash uses, in KiB. Defaults to 65536 (64 MiB). The most effective parameter against GPU attacks.

Iterationsuint32

Passes over the memory. Defaults to 3.

Parallelismuint8

Lanes, computed in parallel. Defaults to 4.

SaltLength, KeyLengthuint32

Lengths of the random salt and of the hash, in bytes. Default to 16 and 32.

MaxConcurrentint

How many hashes are computed at once. Defaults to runtime.GOMAXPROCS(0).

Pepperenv.Secret

A secret mixed into every password. See add a pepper.

Each hash uses Memory while it's computed, so a burst of sign-ins could use it many times over. MaxConcurrent caps how many run at once: other callers wait their turn, or until their context ends, so a flood of sign-in attempts can't exhaust the server's memory. With the defaults, 8 hashes at once use 512 MiB; size MaxConcurrent to the memory you can spare.

Rehash when settings change

Because a hash carries its parameters, raising them doesn't break existing hashes: Verify reads each hash's own parameters. NeedsRehash reports whether a hash was made with other settings, or another algorithm. Check it after a successful sign-in, the only time you have the password:

accounts.go
func (a Accounts) SignIn(ctx context.Context, email, attempt string) (*User, error) {
	user, err := a.Users.FindByEmail(ctx, email)
	if err != nil {
		return nil, err
	}
	ok, err := a.Hasher.Verify(ctx, user.PasswordHash, attempt)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, ErrWrongPassword
	}
	if a.Hasher.NeedsRehash(user.PasswordHash) {
		if hashed, err := a.Hasher.Hash(ctx, attempt); err == nil {
			_ = a.Users.UpdatePasswordHash(ctx, user.ID, hashed)
		}
	}
	return user, nil
}

As users sign in, their hashes move to the current settings.

Move users from another system

Verify also reads the hashes other systems store: scrypt PHC strings ($scrypt$n=16384,r=8,p=1$…) and bcrypt hashes ($2a$, $2b$ and $2y$), the format most web frameworks use. Import the hashes as they are. A stored hash may cost at most 4 times what the Hasher's own hashes cost (and at least 64 MiB and 10 passes are always allowed), and a bcrypt hash a cost of 15, so a corrupted or planted hash can't stall the server; Verify rejects anything costlier with password.ErrUnsupported. NeedsRehash reports true for every one of them, so the rehash above moves each user to Argon2id at their next sign-in, with no password reset.

Add a pepper

A pepper is a secret mixed into every password before hashing. Unlike the salt, it isn't stored with the hash: it lives in the environment, so an attacker who steals the database but not the pepper can't guess passwords at all.

main.go
	hasher := password.New(password.Config{
		Pepper: e.Secret("PASSWORD_PEPPER"),
	})

The pepper applies to the Argon2id hashes the Hasher makes; imported scrypt and bcrypt hashes verify without it, and get it when they're rehashed.

Keep tests fast

Hashing at production cost makes tests that create many users slow. password.Fake() returns a Hasher with the cheapest parameters Argon2 allows, which hashes in microseconds. Use it in tests only: its hashes crack in seconds.

accounts_test.go
func newAccounts(t *testing.T) Accounts {
	return Accounts{Hasher: password.Fake(), Users: newUserStore(t)}
}

See also: Encryption, Configuration & Environment