This version is in beta. Some features may change before release.

Masked fields (encrypt at rest)

Encrypt sensitive columns at rest with public-key cryptography. GDPR-style field encryption with Masked<T>.

Masked fields

Masked<String> is a string field that is stored encrypted in the database and redacted everywhere it would otherwise leak. Use it for PII (phone numbers, national IDs, addresses) and secrets (API keys, OAuth tokens): a stolen database dump leaks ciphertext, not plaintext.

The full surface lives in umbral::orm: Masked, MaskKeyring, MaskError, set_mask_keyring.

Info

Masked is the building block the OAuth plugin uses to store provider access/refresh tokens. Anything you'd be uncomfortable seeing in a pg_dump is a candidate.

How it works

Encryption is public-key (X25519 + XSalsa20-Poly1305 sealed boxes, via the RustCrypto crypto_box crate, in the same anonymous-sender construction as libsodium's crypto_box_seal). A fresh ephemeral keypair is generated per value and sealed to the configured public key. The public key encrypts; the private key decrypts. That asymmetry is the whole point:

  • A write-only tier that holds only the public key can store PII it can never read back. An edge service can capture a phone number that only a separate, locked-down service (holding the private key) can ever decrypt.
  • Crypto-shredding: deleting the private key renders every masked column permanently unrecoverable, a fast bulk "right to be forgotten" that doesn't require touching a single row.

A Masked value is plaintext in memory when you construct it with Masked::new(...), and ciphertext once it has been loaded from the database. The plaintext is only recoverable through an explicit .reveal() call.

What each output shows

This is the most important table on the page. Masked data behaves differently depending on how it's accessed:

AccessResult
Debug ({:?})Masked(••••••) (safe in logs)
Display ({})•••••• (safe in templates)
serde / JSON outputopaque ciphertext (encrypt-on-serialize)
.reveal()the plaintext (needs the private key)
the database columnbase64 ciphertext (TEXT)
Warning

serde output is ciphertext, not the •••••• marker. This is load-bearing: the ORM write path binds INSERT values via serde_json::to_value(instance), so whatever serde emits is what lands in the column: it must be the encrypted value. The plaintext therefore never leaves the process through serde. For a clean REST response, hide the field with the serializer's .hide([...]); Debug and Display stay redacted regardless, so logs and templates are safe by default.

Set up keys

Generate a keypair once with the built-in CLI command:

Code
bash
cargo run -- maskkeygen

It prints two lines. Add them to your environment (a .env file in dev, your secret manager in prod):

Code
bash
UMBRAL_MASK_PUBLIC_KEY=... # encrypts; every tier that WRITES masked data needs it
UMBRAL_MASK_PRIVATE_KEY=... # decrypts / reveal(); keep secret; deleting it crypto-shreds

The keyring is resolved once (an ambient OnceLock, like the DB pool), lazily from the environment on first use. You can also inject it explicitly, useful in tests, or when loading keys from a vault rather than env vars:

Code
rust
use umbral::orm::{MaskKeyring, set_mask_keyring};
 
set_mask_keyring(MaskKeyring::from_base64(&public_b64, Some(&private_b64))?);

A tier configured with only the public key can store masked data but not reveal it: reveal() returns MaskError::NoPrivateKey.

Warning

If a key env-var is set but is malformed (bad base64 or not 32 bytes), every seal/reveal fails with MaskError::BadKey - it never silently falls back to storing plaintext. The error is also logged once at first use so a misconfigured key surfaces in the startup logs. (With the public-key env-var simply absent, masking is treated as not configured, and a seal returns NoKeyring.)

Declaring and using a masked field

Code
rust
use umbral::prelude::*;
 
#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize, serde::Deserialize, Model)]
pub struct Customer {
pub id: i64,
pub name: String,
pub phone: Masked<String>, // encrypted at rest, NOT NULL
pub backup_phone: Option<Masked<String>>, // nullable; None stays SQL NULL
}
 
// Write: plaintext in, sealed on save.
Customer::objects()
.create(Customer {
id: 0,
name: "Ada".into(),
phone: Masked::new("+254712345678"),
backup_phone: None,
})
.await?;
 
// Read: the row loads with ciphertext held internally; reveal() decrypts.
let c = Customer::objects().filter(customer::ID.eq(1)).first().await?.unwrap();
let number = c.phone.reveal()?; // "+254712345678"

The #[derive(Model)] macro recognises Masked<String> and Option<Masked<String>> and maps them to a TEXT column with the masked widget. No extra attributes are required.

API reference

Masked<String>

ItemDescription
Masked::new(plaintext)Construct from plaintext (sealed on the next write).
.reveal() -> Result<String, MaskError>Decrypt. Needs the private key. For an in-memory value, returns it directly without touching the keyring.
.is_revealable() -> boolWhether the value can currently be revealed (in-memory, or keyring has a private key).
Masked::default()An empty masked value (empty plaintext).
From<String> / From<&str>Same as new.

MaskKeyring

ItemDescription
MaskKeyring::generate() -> (String, String)A fresh (public_b64, private_b64) keypair.
MaskKeyring::from_base64(pub, Some(priv))Build a keyring from base64 keys (private optional).
MaskKeyring::from_env()Read UMBRAL_MASK_PUBLIC_KEY / UMBRAL_MASK_PRIVATE_KEY.
.seal(bytes) -> String / .open(ct) -> Result<String, MaskError>Low-level seal/open against this specific keyring (no ambient state).

MaskError

NoKeyring (nothing configured), NoPrivateKey (can store but not reveal), BadKey, Malformed, Decrypt (wrong key or tampered data). Implements std::error::Error.

Limitations

You can't filter or sort by a masked column's value

Each encryption uses a fresh ephemeral key, so the same plaintext seals to different ciphertext every time: WHERE phone = '...' can never match. Masked columns are for store-and-reveal, not querying. If you need to look something up by a sensitive value, store a separate non-reversible hash (e.g. SHA-256) in an indexed column and query that.

Masked fields are excluded from auto-generated forms

A Masked field is treated as #[umbral(noform)] automatically: a struct can derive both Model and umbral::forms::Form with masked fields and they're simply skipped (no compile error, no manual attribute). They're considered server-set: set them in your handler with Masked::new(value).

This is deliberate. The generic form pipeline can't safely round-trip a secret on edit: it can't pre-fill the existing value (the stored value is ciphertext, and revealing it into an HTML input would defeat the point), and a blank resubmit is ambiguous between "no change" and "clear it". So rather than half-support a footgun, masked fields stay off forms.

If you genuinely need user input for a masked value (e.g. a phone number on a signup form), collect it as a plain field and wrap it yourself:

Code
rust
// in your handler, from a normal text input:
let phone = Masked::new(form.get("phone").cloned().unwrap_or_default());
Customer { phone, ..customer }

Ciphertext is larger than the plaintext

A sealed box adds ~48 bytes (ephemeral public key + nonce + auth tag) before base64. Fine for PII-sized fields; not intended for large blobs.

GDPR operations

Erasure (right to be forgotten)

For a clean break across *all* masked data, destroy `UMBRAL_MASK_PRIVATE_KEY`: every masked column becomes permanently unrecoverable (crypto-shredding), no row updates needed. For per-user erasure, overwrite or delete that user's rows as usual.

Key rotation

Keep the old private key available, decrypt-with-old and re-encrypt-with-new in a one-off migration, then retire the old key. (A dedicated rotation command is a planned follow-up; today this is a manual pass.)

Design rationale

See docs/superpowers/specs/2026-06-13-masked-and-oauth-design.md (Component A) for the crypto choice (sealed boxes vs. symmetric AES vs. age), the encrypt-on-serialize integration with the ORM write path, and the GDPR notes.

ormmaskedencryptiongdprpiisecurity