Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tutorial 15: Vault and ORM field encryption

Rullst can encrypt String and Option<String> model fields before they reach the database and decrypt them when a generated ORM query loads the model. The implementation uses AES-256-GCM with a fresh random nonce and an authenticated, versioned envelope.

1. Configure a current key

Generate 32 random bytes and store them as a prefixed base64 value. Do not commit this value to source control:

export RULLST_ENCRYPTION_KEY="base64:$(openssl rand -base64 32)"
export RULLST_ENCRYPTION_KEY_ID="production-2026-01"

RULLST_ENCRYPTION_KEY accepts base64:<value>, hex:<value>, or a legacy raw value containing exactly 32 UTF-8 bytes. A secret manager or KMS-backed deployment adapter should inject it at runtime. Rullst does not provide key custody.

RULLST_ENCRYPTION_KEY, RULLST_ENCRYPTION_KEY_ID and RULLST_ENCRYPTION_KEYRING are each read from the process environment first and, when the process does not set them, from .env in the current working directory, the precedence APP_KEY and rullst::config::project_setting use. The file is read on each lookup and never loaded into the process environment. In development a line such as RULLST_ENCRYPTION_KEY=base64:... in the project’s .env is therefore enough; keep .env out of version control and inject the key through the environment in production.

2. Mark model fields

#![allow(unused)]
fn main() {

#[derive(Clone, Debug, rullst_orm::Orm)]
#[orm(table = "users")]
pub struct User {
    pub id: i32,
    pub email: String,
    #[orm(encrypted)]
    pub tax_id: String,
    #[orm(encrypted)]
    pub recovery_note: Option<String>,
}
}

Generated save, update_partial, find, all, query-builder loads and streams preserve plaintext in the Rust model while storing an envelope shaped like this in the SQL column:

RULLST:v2:<key_id>:<base64url_nonce>:<base64url_ciphertext_and_tag>

The table and column names are authenticated as additional data. Copying a ciphertext into a different annotated column therefore fails decryption.

Generated secondary copies do not undo that protection. to_json(), audit rows and committed-event payloads (including Redis orm:events:* messages) carry the fixed marker "***" instead of the value, and Scout documents omit the field. save_to_redis writes the same authenticated envelope as the SQL column and get_from_redis decrypts it, while the .remember(...) query cache stores the ciphertext read from the database. Audit rows, indexes and Redis hashes written before this behavior may contain plaintext: purge, reindex or rewrite them.

SecretString fields

rullst_orm::SecretString is an alternative for a field that should be encrypted by its SQLx codec and redacted from Debug. Its serde form is never plaintext: Serialize writes a RULLST:v2 envelope under the current key (and fails without one), and Deserialize decrypts such an envelope with the current key or keyring while still accepting an ordinary string as input. Generated audit, event and search projections write "***" instead, and the .remember(...) query cache stores the envelope. A #[derive(Serialize)] on the model therefore produces ciphertext for this field; call reveal_audited() where the plaintext is deliberately required.

3. Rotate a key without downtime

Set the new current key and keep old readable keys in a JSON keyring:

export RULLST_ENCRYPTION_KEY="base64:<new-32-byte-key>"
export RULLST_ENCRYPTION_KEY_ID="production-2027-01"
export RULLST_ENCRYPTION_KEYRING='{
  "production-2026-01": "base64:<old-32-byte-key>"
}'

Reads select the key named by the envelope. A normal full save() rewrites all annotated values with the current key. Keep every key needed by existing rows until a separately monitored migration has rewritten and verified them; then remove the retired key.

4. Understand query limits

AES-GCM encryption is randomized, so the same plaintext produces different ciphertexts. Generated queries reject encrypted fields in WHERE, ORDER BY, GROUP BY, and explicit SELECT clauses rather than silently returning wrong results. pluck_string supports non-null encrypted strings; load the model for nullable encrypted strings.

For lookup, add a separate application-designed blind-index column and assess its equality-leakage and key-rotation trade-offs. Rullst does not generate a blind index automatically. Raw SQL is an explicit escape hatch and does not automatically encrypt bindings or decrypt arbitrary projections.

5. Reduce secret lifetime in memory

VaultSecret<T> redacts Debug/Display and calls Zeroize on the wrapped value when dropped:

#![allow(unused)]
fn main() {
use rullst_security::VaultSecret;

fn use_api_key() {
    let Ok(value) = std::env::var("EXAMPLE_PROVIDER_KEY") else {
        return;
    };
    let api_key = VaultSecret::new(value);
    send_request(api_key.expose_secret());
}

fn send_request(_: &str) {}
}

Zeroization is defense in depth, not secure memory. It cannot erase prior copies or prevent a debugger, core dump, swap, allocator behavior, or another process-memory capture while the secret is live.

Operational checklist

  • Back up the database and keyring together, with separate access controls.
  • Test restore and rotation before retiring any key.
  • Restrict environment and crash-dump access.
  • Never log plaintext model fields or expose them through serialization by accident. Generated to_json() already replaces encrypted fields with "***"; add #[orm(hidden)] when a field must be omitted entirely. A #[derive(Serialize)] on the model is your own serializer and still emits the decrypted value.
  • Treat authentication failure as possible corruption, wrong context, wrong key, or tampering; do not replace it with an empty value.