Pular para o conteúdo
Esta página ainda não foi traduzida. O conteúdo abaixo está em inglês. Ler em inglês

Security

Encryption

AES-256-GCM encryption derived from APP_KEY, with key rotation, encrypted cookies and encrypted ORM columns.

Última atualização:

Package crypt (stdlib only). app.Crypt is built at boot from a key derived with HKDF from APP_KEY, plus keys derived from APP_PREVIOUS_KEYS. Outside production an app may boot without APP_KEY; app.Crypt is then nil and fails closed (crypt.ErrNoKey from Decrypt and SetCookie, a panic from Encrypt).

Go
token := app.Crypt.EncryptString("4111 1111 1111 1111")
plain, err := app.Crypt.DecryptString(token) // crypt.ErrInvalid on any failure

// Additional data binds a token to its purpose; Open needs the same bytes.
token = app.Crypt.Seal(payload, []byte("invoice:42"))
payload, err = app.Crypt.Open(token, []byte("invoice:42"))
  • AES-256-GCM with a random 96-bit nonce per token; keys are 32 bytes (crypt.New(current, previous...)).
  • A token is unpadded base64url of version | key id | nonce | ciphertext | tag. The key id selects the key; the header is authenticated with the additional data.
  • Every failure (malformed, tampered, unknown key, other additional data) returns crypt.ErrInvalid, without telling which.

Key rotation#

Terminal
# .env
APP_KEY=base64:new…
APP_PREVIOUS_KEYS=base64:old…,base64:older…

Previous keys only open or verify; everything new is sealed or signed with APP_KEY. A malformed entry is a boot error. They cover every feature keyed by APP_KEY:

  • app.Crypt: encrypted cookies, orm.Encrypted columns and queued mail payloads.
  • Cookie sessions, with the CSRF token and flash data they carry: re-sealed with the new key on the next request that reads them.
  • The maintenance bypass cookie: re-issued under the new key.
  • Signed URLs and social login state, when the app passes its previous keys (the install:auth scaffolds do):
Go
signer := signed.New(key).WithPreviousKeys(app.DerivePreviousKeys("signed.urls", 32)...)
s := social.New(key, providers...).WithPreviousKeys(app.DerivePreviousKeys("social.state", 32)...)

Remember-me, API and refresh tokens are stored as hashes, and JWT keys are configured separately, so they do not depend on APP_KEY.

Go
if app.Crypt.NeedsRotation(token) { // sealed with a previous key
	token, err = app.Crypt.Rotate(token, []byte("invoice:42"))
}

To rotate:

  1. Locally, run vukem key:rotate: it writes a new APP_KEY to .env and moves the old one to the front of APP_PREVIOUS_KEYS (no key is printed). In production, generate a key (vukem key:generate --show or ./app key:generate), set APP_PREVIOUS_KEYS=<old key> and APP_KEY=<new key> in the secret store, and deploy every instance with both.
  2. Wait for what the old key signed to expire: SESSION_LIFETIME, the TTL of signed links already sent (links without a TTL stop working), queued and failed mail jobs, 12 hours for maintenance bypass cookies. Re-save rows with encrypted columns (each save re-seals them) and re-seal other stored tokens with NeedsRotation / Rotate.
  3. Remove the old key. Anything still sealed with it fails closed: the session is empty, signed links answer 403, mail jobs fail and encrypted columns return crypt.ErrInvalid.

A previous key still verifies sessions and signed URLs, so whoever holds it can forge them during the window. After a leak, re-encrypt stored data in a short maintenance window that has the leaked key, then drop it at once instead of keeping it in APP_PREVIOUS_KEYS.

Encrypted cookies#

Go
err := app.Crypt.SetCookie(w, r, http.Cookie{Name: "cart", Value: "3", Path: "/"})
cart, err := app.Crypt.Cookie(r, "cart") // http.ErrNoCookie or crypt.ErrInvalid

The cookie name is the additional data. SetCookie always sets HttpOnly, defaults SameSite to Lax, adds Secure when the request came over TLS (set it yourself behind a TLS-terminating proxy), refuses SameSite=None without Secure, and refuses values over 4 KB (crypt.ErrCookieTooLarge).

Encrypted columns#

orm.Encrypted[T] stores T as JSON encrypted with the connection's encrypter. app.DB() passes app.Crypt; elsewhere use database.Open(ctx, url, database.WithEncrypter(enc)).

Go
type Patient struct {
	orm.Model
	Name      string
	Diagnosis orm.Encrypted[string] `json:"-"` // TEXT column
}

p := Patient{Name: "Ada", Diagnosis: orm.Encrypted[string]{V: "flu"}}
err := orm.Create(ctx, db, &p)                       // stores ciphertext
got, err := orm.QueryOf[Patient](db).Find(ctx, p.ID) // got.Diagnosis.V == "flu"
  • Sealed on Create, Update and Query.Update; opened by queries, eager loads and Pluck. The additional data is orm:<table>.<column>, so a ciphertext copied to another column or table is rejected; renaming the table or column needs a re-encryption.
  • Fails closed: without an encrypter, writes and reads return orm.ErrNoEncrypter and nothing is stored. Passing an Encrypted value to database/sql directly is an error, so plaintext never reaches the database by accident.
  • Saving a model re-encrypts every encrypted column with the current key.
  • Not queryable: encryption is randomized, so Where("diagnosis", …) cannot match. NULL reads as the zero value.
  • JSON encoding uses the plain value; tag the field json:"-" to keep it out of API responses.

Two-factor authentication stores its secrets and recovery codes in encrypted columns.