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.