Encryption
This page shows how to encrypt and decrypt text with Crypt and AESEncryptor.
Dox uses AESEncryptor to encrypt cookies with your APP_KEY.
You can also call it yourself.
Encrypt with the app key
Crypt uses the APP_KEY of the app. You do not pass the key.
String secret = Crypt.encryptString('card-1234');
String card = Crypt.decryptString(secret); // 'card-1234'
String data = Crypt.encrypt(<String, dynamic>{'id': 5}); // any JSON value
Map<String, dynamic> map = Crypt.decrypt(data);
decryptStringanddecryptalso try the old keys inAppConfig.previousKeys.- They throw
DecryptExceptionwhen the text was changed or made with another key. Crypt.encryptString(v)gives the same asAESEncryptor.encode(v, appKey).
Encrypt and decrypt with your own key
import 'package:dox_core/dox_core.dart';
String secret = env.appKey;
String encoded = AESEncryptor.encode('Hello world', secret);
String decoded = AESEncryptor.decode(encoded, secret); // 'Hello world'
encodereturns base64 text.- The same text gives a different output each time.
decodethrowsDecryptExceptionwhen the text was changed, or the key is wrong. It is a400 Bad Requesterror.- The AES key is not the secret itself. Dox makes a 32-byte key (AES-256) from the secret with HKDF-SHA256 (RFC 5869) and a label. The default label is
dox:encrypt. Pick another one withpurpose:, likeAESEncryptor.encode(text, secret, purpose: 'app:invoice').decodeneeds the samepurpose.
Make a new 32-character APP_KEY with the CLI:
It writes a random key of letters and digits to APP_KEY in .env.
One key per purpose
Dox does not use APP_KEY as it is. It makes one key per purpose from it, with HKDF-SHA256 and a label:
| Label | Used for |
|---|---|
dox:cookie |
encrypted cookies, and the cookie session driver |
dox:jwt |
the HMAC key of JwtAuthDriver (with a SecretKey) |
dox:encrypt |
AESEncryptor when you give no purpose |
dox:signed-url |
for signed URLs |
So a value made for one purpose can not be used for another one.
KeyDerivation.forAppKey(label) gives the 32-byte key of a label. Each isolate makes it once.
At startup Dox checks APP_KEY. When it is empty, not exactly 32 bytes, or an old public sample key, the app stops.
In development, local, test and testing it only prints a warning.
- Keep
APP_KEYin.envor in the secrets of your host. Never put it in code or in git. - If you change
APP_KEYwith nopreviousKeys, old encrypted cookies and sessions can not be read any more, so every user is logged out. JWT tokens signed with it stop working too. See Change the key.
Change the key (key rotation)
Change APP_KEY from time to time, or when someone who knew it leaves the team.
With previousKeys, users stay logged in while you change it.
Steps
- Copy the current
APP_KEYtoAPP_PREVIOUS_KEYS. -
Run
dox key:generate. It writes a newAPP_KEY. Now.envhas both: -
Read it in the env class, and give it to
AppConfig: -
Deploy. Every instance must get the new env at the same time.
- Wait until the old cookies have expired: the session
lifetime, and themaxAgeof your own cookies. - Remove the old key from
APP_PREVIOUS_KEYSand deploy again.
APP_PREVIOUS_KEYS can hold more than one key, comma separated. Dox tries them in order.
What reads the old keys
| Value | With an old key |
|---|---|
encrypted cookies (req.cookie) |
read |
cookie session driver |
read |
| CSRF token | read (it is in the session) |
JWT tokens of JwtAuthDriver(secret: SecretKey(env.appKey)) |
checked. A JWT secret that is not APP_KEY needs its own previousSecrets option, see Authentication. |
Crypt.decryptString and Crypt.decrypt |
read |
AESEncryptor.decode in your code |
read only when you pass previousSecrets: Dox().config.previousKeys |
| anything new | made with APP_KEY only |
Dox writes a cookie with the new key the next time it sends that cookie. So a user who comes back during step 5 moves to the new key without noticing.
When the key leaked
Do not put a leaked key in previousKeys.
Anyone who has it can still make cookies and JWT tokens that Dox accepts.
Change APP_KEY with no previousKeys. Every user is logged out, and that is what you want.
Cookies
DoxCookie encrypts its value with APP_KEY by default.
req.cookie(key) decrypts it.
To decrypt a raw cookie value yourself, call DoxCookie.decrypt(name, value). It returns null when the value was changed, was made with another key, or was made for another cookie name.
return response().cookie(DoxCookie('theme', 'dark')); // encrypted
DoxCookie('theme', 'dark', encrypt: false); // plain text
String? theme = req.cookie('theme'); // decrypted
String? raw = req.cookie('theme', decrypt: false); // as sent
req.cookie(key) returns null when the cookie was changed, was made with another APP_KEY, or was made for another cookie name.
The cookie name is bound to the encrypted value, so a value can not be copied from one cookie to another.
How it works
AESEncryptor uses AES-256-GCM. The work runs in Rust (in dox_http_native), with the AES instructions of the CPU. One cookie takes about 1–2 µs.
- Each message gets a new random 12-byte nonce.
- The output is base64 of
nonce + encrypted text + tag. - The 16-byte tag checks the message. If someone changes one byte,
decodethrows. encodeanddecodealso takeassociatedData, a text like a cookie name. It is not in the output, butdecodeneeds the same value. Cookies use the cookie name here.
To store passwords, do not encrypt them. Hash them. See Hashing.