navree

Encryption and hashing

The application key, AES-256-GCM encryption with key rotation, Argon2id password hashing and secure token helpers.

On this page

navree-crypto holds the cryptography the rest of Navree builds on, and you can use it directly:

  • Encrypter encrypts values with the application key (AES-256-GCM), and still decrypts them after you rotate the key;
  • Hasher hashes passwords with Argon2id, off the async worker threads;
  • helpers make random tokens, digests and signatures, and derive keys for other purposes from the application key.

Keys, plaintexts and passwords never appear in Debug output or in error messages, so both are safe to log.

Setting up

Featurecrypto The crypto feature of the navree facade (part of the default full feature) brings navree-crypto. CryptoModule is global and provides Encrypter and Hasher to every module:

src/main.rs

CryptoModule::from_config(), or a plain .module(CryptoModule), reads the application key, the previous keys and the hashing section when the app boots. To pass them in code instead, use CryptoModule::for_root:

Rust

A malformed key or invalid hashing options fail the boot, and the message names the option, never its value. A missing key doesn't: the app boots with a warning, so that key:generate can run, and every attempt to encrypt fails until a key is set.

The application key

The application key is 32 random bytes (256 bits), written as base64: followed by their standard base64. Generate one with the key:generate command that CryptoModule registers:

Shell
Text

Like every app command, key:generate boots the whole app first, so in an app with DatabaseModule the database must be reachable.

Put it in .env, which stays out of version control:

.env

APP_KEY maps to the top-level configuration key key; app.key (environment APP_APP__KEY) works too and wins when both are set. Surrounding whitespace is ignored, an empty value counts as unset, and a value that isn't base64: plus exactly 32 bytes fails the boot. In code, navree::crypto::generate_key() returns a new key in the same form.

Every process of the app (serve, queue:work, schedule:work) and every instance must use the same key. It protects more than encrypted values:

WhatHowWithout a key
Encrypted values: two-factor secrets and recovery codes, session cookies, your ownEncrypterEncrypting fails
JWT access tokens, unless auth.jwt.secret is setA key derived for navree-auth.jwtA random key per process
Signed URLs such as email verification links, unless http.signing_key is setAn HMAC of the application keyA random key per process
Temporary file URLsA key derived from the application keyA random key per process
Broadcasts that workers relay to serve, unless realtime.secret is setA key derived from the application key, only through CryptoModuleBroadcasts stay in their process

The application key never signs or encrypts anything for those other purposes itself: each gets its own derived key.

Encrypting values

Inject Arc<Encrypter> wherever you need it. This service stores the credentials of a third-party integration:

src/modules/integrations/vault.rs
Method
encrypt(&[u8]), decrypt(&str)Bytes
encrypt_string(&str), decrypt_string(&str)UTF-8 text
encrypt_json(&T), decrypt_json::<T>(&str)Any Serialize / DeserializeOwned value, as JSON
current_kid()The id of the key new payloads use, or None without a key
derive_key(purpose)A key for another purpose; see Derived keys

Each call encrypts with AES-256-GCM under a fresh random 96-bit nonce, so encrypting the same value twice gives two different payloads. Store payloads in a text column.

There are no encrypted model attributes: encrypt before you write a column and decrypt after you read it, as the vault does.

The payload format

A payload is a compact string, safe in URLs and cookies:

Text
Part
v1The format version
_f81B5zBThe key id: eight characters from a SHA-256 of the key, which can't be turned back into the key. It picks the key to decrypt with
eUcgbYYL9SDjskriThe nonce
the restThe ciphertext and its 128-bit authentication tag

The parts are base64url without padding. The v1.<kid> header is authenticated with the ciphertext, so changing any part makes decryption fail. The header, the nonce and the tag make a payload about 50 characters longer than the base64 of its plaintext.

Errors

Every method returns navree::crypto::CryptoError. Its messages never quote keys, plaintexts or decrypted data:

ErrorWhen
MissingKeyNo application key is configured
InvalidKeyA key isn't base64: plus 32 bytes
InvalidPayloadThe payload isn't v1.<kid>.<nonce>.<ciphertext>
DecryptThe payload was changed, or its key isn't configured any more
NotUtf8, JsonThe decrypted bytes aren't the text or the JSON type asked for (Json gives only the category: syntax, data, eof or io)
EncryptThe plaintext is too long for AES-GCM
InvalidHash, InvalidHashingOptions, HashingPassword hashing; see below

Rotating keys

Rotate the application key when it may have leaked, or on a schedule. Old payloads stay readable: the current key encrypts, and previous keys only decrypt.

  1. Generate a new key

    Shell
  2. Move the current key to the previous keys

    .env

    APP_PREVIOUS_KEYS (or app.previous_keys) is a comma-separated list, most recent first. In code it is CryptoOptions::previous_key.

  3. Restart every process

    Every process must read the same keys.

From then on, new payloads use the new key, and payloads made with an old key still decrypt: their key id picks the key. Nothing is re-encrypted for you, and there is no command for it. A value stays encrypted with the old key until your code writes it again, so keep an old key in app.previous_keys until nothing encrypted with it remains. That includes the two-factor secrets and recovery codes of navree-auth: drop the old key too early and those users can't sign in with a second factor.

Previous keys only help to decrypt. Everything derived from the application key changes with it:

  • access tokens signed with a derived key fail with 401 Invalid token; clients refresh, because refresh tokens are stored as digests and don't depend on the key;
  • signed links already mailed (email verification) and temporary file URLs stop working;
  • workers and serve must restart together, or relayed broadcasts fail their signature check.

Set auth.jwt.secret, http.signing_key or realtime.secret when you want to rotate one of those separately.

The same works on a standalone Encrypter:

Rust

Derived keys

Encrypter::derive_key(purpose) returns a 32-byte key for one purpose: an HMAC-SHA-256 of the application key over navree-crypto/derive/v1: and the purpose. The same key and purpose always give the same derived key, and knowing a derived key reveals nothing about the application key or about other purposes. Use it to sign things with hmac_sha256 without reusing the encryption key:

Rust

Derived keys come from the current key only, so they change when you rotate it. derive_key fails with MissingKey without an application key.

Hashing passwords

Inject Arc<Hasher> to hash and check secrets that people type. navree-auth uses it for passwords; this service uses it for PINs:

src/modules/kiosk/pins.rs
  • hash(password) returns a PHC string with a fresh random salt:

    Text
  • verify(password, hash) is Ok(true) or Ok(false). It uses the parameters stored in the hash, so old hashes keep verifying after you change the options. A string that isn't an Argon2 hash, such as a bcrypt hash from another system, is Err(CryptoError::InvalidHash).

  • needs_rehash(hash) is true when the hash isn't Argon2id version 19 with exactly the configured parameters. Hash the password again after a successful verify and store the new hash. navree-auth does this at every sign-in.

Parameters

The defaults are OWASP's minimum recommendation for Argon2id:

config/app.toml
KeyDefault
hashing.memory_kib19456Memory per hash, in KiB (Argon2's m)
hashing.iterations2Passes over the memory (t)
hashing.parallelism1Lanes (p)
hashing.max_concurrent_hashesthe number of CPUsHow many hashes may run at once; at least 1

Raising a cost makes needs_rehash true for existing hashes, so users' hashes are upgraded as they sign in. Values outside Argon2's limits, and max_concurrent_hashes = 0, fail the boot with a message that names hashing.

Argon2id is slow and memory-hungry on purpose. Every hash and verify runs on Tokio's blocking thread pool, never on an async worker, behind a semaphore of max_concurrent_hashes permits. A permit is held until the hash finishes, even if the request that started it is cancelled, so a burst of logins waits its turn instead of exhausting memory. The peak is about max_concurrent_hashes × memory_kib: 8 × 19 MiB is 152 MiB on an 8-core machine. Lower max_concurrent_hashes on small machines.

Tokens and digests

Function
random_token(len)len random characters from [A-Za-z0-9], from a cryptographically secure generator. 40 characters carry about 238 bits
sha256_hex(data)The SHA-256 digest as 64 lower-case hex characters
hmac_sha256(key, msg)HMAC-SHA-256 as 32 bytes; any key length
constant_time_eq(a, b)Compares two byte strings in a time that depends only on their lengths
generate_key()A new application key, base64:...

AppKey is the parsed key: AppKey::parse, AppKey::generate, kid(). Its Debug output is AppKey([redacted]).

The usual pattern for an API token or a reset link: show the token once and store only its digest, then compare digests in constant time:

Rust

Navree's own tokens work this way: refresh tokens (an id plus 48 random characters), password reset tokens (64) and two-factor recovery codes are all stored as SHA-256 digests (the recovery codes' digests are encrypted too).

Limits

  • One cipher (AES-256-GCM) and one application key at a time, plus previous keys for decryption. There are no per-record keys and no key management service integration.
  • Nothing re-encrypts stored values after a rotation.
  • Password hashes are Argon2id only: hashes from another system (bcrypt, say) can't be verified, so migrate those users with a password reset.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks