Encryption
This guide covers encryption in Tinka. You will learn how to:
- Encrypt and decrypt values with the App's key
- Bind a value to its purpose, and make it expire
- Sign values that may be read but must not be changed
- Find encrypted values in a database with blind indexes
- Rotate the App's key without breaking existing values
Overview
Encryption turns a value into text only your App can read back. Unlike a password hash, it's reversible: you encrypt a value to store or send it safely, and decrypt it when you need it. The encryption package (github.com/mucolabs/tinka/encryption) encrypts with your App's key, APP_KEY, and authenticates every value: one that was changed, made with another key, or made for another purpose fails to decrypt instead of returning garbage.
Values are encrypted with AES-256-GCM, under a key derived for each value from APP_KEY, so one key can encrypt any number of values safely. The output is URL-safe text, ready for a cookie, a link or a database column.
Create an Encrypter
Create one Encrypter from the App's key when the App starts, and share it. act generate:key writes a key to your .env file, as the configuration guide shows:
e := env.Load()
enc, err := encryption.New(e.Secret("APP_KEY"))
if err != nil {
return err // a missing or short key
}New fails for a key shorter than 32 characters, so a placeholder key never reaches production. An Encrypter is safe for concurrent use, and printing or logging it never shows its key.
Encrypt and decrypt values
Encrypt encodes a value as JSON and encrypts it. Decrypt returns it as the type you name:
package main
import (
"errors"
"github.com/mucolabs/tinka"
"github.com/mucolabs/tinka/encryption"
)
type Invite struct {
Email string `json:"email"`
TeamID int64 `json:"team_id"`
}
type Invites struct {
Enc *encryption.Encrypter
}
func (h Invites) Link(invite Invite) (string, error) {
token, err := h.Enc.Encrypt(invite)
if err != nil {
return "", err
}
return "https://example.com/invites/" + token, nil
}
func (h Invites) Accept(c *tinka.Ctx) (Invite, error) {
invite, err := h.Enc.Decrypt[Invite](c.Param("token"))
if errors.Is(err, encryption.ErrInvalid) {
return Invite{}, tinka.NotFound("This invitation link is not valid.")
}
return invite, err
}Decrypt fails with encryption.ErrInvalid for any value it can't trust: changed, cut short, made with another key, or made for another purpose. One error covers every case, so a failure tells an attacker nothing about which check failed. Encrypting the same value twice gives different outputs, so equal values can't be spotted in a database.
Bind values to a purpose
A purpose binds a value to what it's for. It decrypts only with the same purpose, so a token made for one use can't be replayed for another, even when both hold the same data:
token, err := enc.Encrypt(user.ID, encryption.Purpose("password-reset"))
id, err := enc.Decrypt[int64](token, encryption.Purpose("password-reset")) // ok
id, err = enc.Decrypt[int64](token, encryption.Purpose("email-verification")) // ErrInvalid
id, err = enc.Decrypt[int64](token) // ErrInvalidMake values expire
ExpiresIn gives a value a lifetime. Once it passes, Decrypt fails with encryption.ErrExpired, which you can tell apart from an invalid value to show a clearer message:
token, err := h.Enc.Encrypt(invite, encryption.ExpiresIn(7*24*time.Hour), encryption.Purpose("invite"))
invite, err := h.Enc.Decrypt[Invite](c.Param("token"), encryption.Purpose("invite"))
switch {
case errors.Is(err, encryption.ErrExpired):
return Invite{}, tinka.NotFound("This invitation has expired. Ask for a new one.")
case errors.Is(err, encryption.ErrInvalid):
return Invite{}, tinka.NotFound("This invitation link is not valid.")
}The expiry is inside the encrypted value, so it can't be changed without breaking the value.
Sign values without hiding them
Sign protects a value from changes without hiding it: anyone can read a signed value, but Verify fails for one that was changed. Use it for data that isn't secret but must be trusted when it comes back, such as the user ID in an unsubscribe link. Sign takes the same Purpose and ExpiresIn options:
link, err := enc.Sign(user.ID, encryption.Purpose("unsubscribe"))
id, err := enc.Verify[int64](token, encryption.Purpose("unsubscribe"))Signed and encrypted values are never interchangeable: each uses its own key, derived from APP_KEY.
Find encrypted values
Encrypted values can't be searched: the same email address encrypts differently every time, so WHERE email = ? never matches. A blind index solves this. It's a keyed hash of the value, stored in its own column and used only to look rows up. Equal values give equal indexes, but the index reveals nothing about the value without the key.
The purpose names the column, so the same value gives different indexes in different columns, and they can't be compared:
email = strings.ToLower(strings.TrimSpace(email)) // indexes match exact values
encrypted, err := enc.Encrypt(email)
if err != nil {
return err
}
_, err = db.ExecContext(ctx,
"INSERT INTO users (email, email_index) VALUES ($1, $2)",
encrypted, enc.BlindIndex(email, "users.email")) row := db.QueryRowContext(ctx,
"SELECT id, email FROM users WHERE email_index = $1",
enc.BlindIndex(strings.ToLower(strings.TrimSpace(email)), "users.email"))Normalize a value before indexing it, as above: Ada@example.com and ada@example.com give different indexes.
Rotate the App's key
Pass several keys to New, the newest first. The first key encrypts and signs every new value, and every key decrypts and verifies, so values made with an older key keep working while you replace it:
enc, err := encryption.New(e.Secret("APP_KEY"), e.Secret("OLD_APP_KEY"))To rotate the key:
- Generate a new key with
act generate:key --show. - Set
APP_KEYto the new key, andOLD_APP_KEYto the previous one. - Deploy.
- Once the values made with the old key have expired or been encrypted again, remove
OLD_APP_KEY.
Blind indexes depend on the key too. While rotating, look rows up with BlindIndexes, which returns the index under every key, and write new rows with BlindIndex, which uses the newest:
indexes := enc.BlindIndexes(email, "users.email")
rows, err := db.QueryContext(ctx, "SELECT id FROM users WHERE email_index = ANY($1)", indexes)See also: Response for signed and encrypted cookies, URL builder for signed URLs, Configuration & Environment