Encryption
On this page
Encrypt what the client must not read, and sign what it may read but not
change — one Crypt for both halves.
use rainier_framework::prelude::*;
let sealed = Crypt::instance().encrypt("a card number")?;
assert_eq!(Crypt::instance().decrypt(&sealed)?, "a card number");
Encrypt or sign?
The first question, and the one people get wrong.
| Hidden | Tamper-evident | For | |
|---|---|---|---|
encrypt | yes | yes | anything the client must not read |
sign | no | yes | anything the client may read but not change |
// The client must not see this.
let sealed = Crypt::instance().encrypt(&card_number)?;
// The client may see this; it must not be able to change the 42.
let signed = Crypt::instance().sign("unsubscribe-42")?;
// "unsubscribe-42.a1b2c3d4.k9x…"
Reach for signing when the value is not a secret. An unsubscribe link, a reset token, a "remember this choice" cookie — encrypting those makes them opaque in your own logs, to your own support staff, and to you, for no gain.
The API
let crypt = app.resolve::<Encryption>()?; // or Crypt::instance()
crypt.encrypt("plain")?; // String -> payload
crypt.decrypt(&payload)?; // payload -> String
crypt.encrypt_bytes(&bytes)?; // raw
crypt.decrypt_bytes(&payload)?;
crypt.encrypt_json(&value)?; // anything Serialize
crypt.decrypt_json::<T>(&payload)?;
crypt.sign("value")?; // value.keyid.tag
crypt.verify(&signed)?; // -> the value, or Err
crypt.is_valid(&signed); // -> bool
Encryption is a newtype over two ports, Encrypter and Signer, so
replacing either does not change a call site — the same shape as
Views.
Configuring it
APP_KEY=base64:FhtFZdyUeiN5XAx1am30quaV9jUjm2pFvIzVBw9pdQg=
APP_PREVIOUS_KEYS=
cargo run -- key:generate
The base64: prefix is optional and accepted so a key generated for a PHP
application can be pasted straight in — which matters when the two are running
side by side during a migration.
Without APP_KEY, a random key is minted per boot. Everything works, and
every encrypted value from the last boot becomes unreadable. That is loud rather
than silent:
WARN APP_KEY is not set; generating a temporary key. Set one with
`APP_KEY=base64:…` or nothing encrypted will survive a restart.
A malformed APP_KEY gets the same treatment plus an error, rather than a
decrypt failure three screens away from the cause.
Key rotation
Rotation is designed in, not deferred. Every payload records which key wrote
it, and a KeyRing holds one current key plus any number of retired ones:
APP_KEY=base64:<the new key>
APP_PREVIOUS_KEYS=base64:<the old key>,base64:<an older one>
That is the whole procedure: move the current key into APP_PREVIOUS_KEYS, put
a new one in APP_KEY, deploy. Anything already written still reads; anything
new uses the new key.
flowchart LR
subgraph ring [The key ring]
C["current — a1b2c3d4"]
P1["retired — 9f8e7d6c"]
P2["retired — 5a4b3c2d"]
end
W["encrypt(..)"] --> C
C --> N["xc20p.a1b2c3d4.nonce.ct"]
O1["xc20p.9f8e7d6c.nonce.ct"] --> P1
O2["xc20p.5a4b3c2d.nonce.ct"] --> P2
N -.-> C
P1 --> R["decrypt — still readable"]
P2 --> R
C --> R
style C fill:#353,stroke:#6a6,color:#fff
Do not remove a retired key while payloads written with it still exist. That case has its own error, because it is an operator mistake rather than an attack and the fix is to put the key back:
no key with id `9f8e7d6c` is on the ring; it is still needed to read
payloads written with it
Key ids are derived from the key material (a truncated hash), so they cannot be configured inconsistently across deployments and cannot be forgotten when a key is added.
Ciphers
The default is XChaCha20-Poly1305, and it is the one to use. Its 192-bit nonce is large enough that a random nonce per message has no meaningful collision risk — so there is no counter to keep, no state to synchronise between processes, and no way to catastrophically reuse one.
| Cipher | Id | Nonce | Notes |
|---|---|---|---|
XChaCha20Poly1305 | xc20p | 192-bit | the default |
ChaCha20Poly1305 | c20p | 96-bit | as used by TLS |
Aes256Gcm | a256gcm | 96-bit | for interop, or hardware AES |
Aes128Gcm | a128gcm | 96-bit | shorter key, same material |
Aes256GcmSiv | a256siv | 96-bit | nonce-misuse resistant |
use rainier_framework::crypt::{Cipher, Encryption, KeyRing};
Encryption::from_keys_with(keys, Cipher::Aes256Gcm)
The 96-bit ciphers have a real birthday bound with random nonces — safe for a
few billion messages under one key, and a reason to prefer the X variant when
you are not constrained by a peer. AES-GCM's failure on a repeated nonce is also
harsher: it leaks the authentication key, not just a plaintext relationship.
Aes256GcmSiv is the one where a repeat reveals only that two plaintexts were
equal, which is worth choosing when nonces come from somewhere you do not fully
control.
Switching cipher is a deploy, not a migration. The payload names its own algorithm, so a ring reads all five and writes with one:
let written = AeadEncrypter::new(keys.clone()).encrypt("before")?;
let now = AeadEncrypter::new(keys).with_cipher(Cipher::Aes256Gcm);
assert_eq!(now.decrypt(&written)?, "before"); // the old payload still reads
assert!(now.encrypt("after")?.starts_with("a256gcm."));
Each cipher's key is derived from the ring key by HKDF with the cipher's id
as info. That fits AES-128's shorter key, and more importantly gives every
algorithm a different key from the same material — so encrypting one value under
two ciphers does not reuse key bytes across primitives.
HMAC-SHA256 for signing, compared in constant time. A byte-by-byte comparison leaks how much of a forged tag was correct, which is enough to construct the rest of it.
The payload format
xc20p.a1b2c3d4.<nonce base64url>.<ciphertext base64url>
Algorithm, key id, nonce, ciphertext — URL-safe throughout, so a payload goes in a query string or a cookie without further encoding.
The xc20p.a1b2c3d4 header is the AEAD's associated data: authenticated but
not encrypted. So a payload cannot be relabelled with a different cipher or key
id and still open.
v1 is accepted on read as an alias for xc20p, because that is what the first
release wrote.
Failures are indistinguishable
crypt.decrypt("xc20p.abc.def.ghi") // Err — 400
crypt.decrypt("nonsense") // Err — 400, same message
crypt.decrypt(&tampered) // Err — 400, same message
Every malformed-or-forged case reports the same thing. Distinguishing "the padding was wrong" from "the tag was wrong" is how padding-oracle attacks start, and a caller can do nothing useful with the difference.
The one exception is a missing key, which is a 500 with a specific
message, because that one is yours to fix.
Equal plaintexts are not detectably equal
crypt.encrypt("same")? != crypt.encrypt("same")?
A fresh random nonce per message. Which means an encrypted column is safe to store and cannot be searched or compared for equality — if you need to look a value up, store a separate keyed hash of it to index on.
Signing
let signed = crypt.sign("unsubscribe-42")?; // "unsubscribe-42.a1b2c3d4.tag"
let value = crypt.verify(&signed)?; // "unsubscribe-42"
The value stays readable, which is the whole point.
Two constraints:
- The value must not contain a
.— the format is dot-separated and the value comes first. A structured value is refused rather than mangled, with a message telling you to encode it first. - The key id is folded into the tag, so a signature cannot be replayed under a different key's label.
Retired keys verify what they signed, exactly as with encryption.
JWTs and a JWKS document
Behind the jwt feature — an application that issues no tokens should not
compile an RSA implementation.
let jwt = Jwt::new(ring)
.issued_by("https://id.example.com")
.for_audience("api");
let token = jwt.sign(&claims)?;
let claims: Claims = jwt.verify(&token)?;
// GET /.well-known/jwks.json
Response::json(&jwt.jwks())
RS256 and ES256. Both asymmetric, and both what relying parties actually ask for — OIDC expects RS256, which is why Ed25519 alone was not enough for an identity provider.
It is not only for issuers. Anything that verifies a third-party token — a
Google ID token, an Apple one, a Kubernetes service-account token — needs the
same ring keyed by kid and the same rule about which keys are still
acceptable.
Rotation is an overlap, not a switch
let ring = JwtKeyRing::new(JwtKey::rs256_from_pem("2026-07", ¤t)?)
.with_previous(JwtKey::rs256_from_pem("2026-01", &previous)?);
The ring signs with the newest key and verifies against every key it holds. Retiring one is two steps: stop signing with it, then — once every token it signed has expired — remove it.
The JWKS lists all of them, which is what makes that work for somebody else's verifier: a relying party that refreshes its copy keeps accepting tokens across the change. Publishing only the signing key is the classic mistake, and it invalidates every unexpired token the previous one issued.
The algorithm comes from the key, never the token
A verifier that trusts the header's alg is the classic JWT vulnerability: a
forged header saying none, or saying HS256 over a public key everybody has.
Here the kid selects a key and the key says which algorithm to verify
with. A token naming a key this service does not hold is refused without
spending CPU on it.
HS256 is deliberately absent. A symmetric JWT cannot be verified by anyone
who cannot also mint one, which makes a published JWKS meaningless.
Keys
JwtKey::rs256_from_pem("2026-07", &pem)? // PKCS#8 or PKCS#1
JwtKey::es256_from_pem("2026-07", &pem)?
JwtKey::generate_rs256("2026-07", 2048)? // tests, and a first boot
The kid travels in every token's header, so it has to be stable across
restarts — a random one per boot means every token issued before the last
deploy stops verifying. Generating a key at boot has the same problem, which
is why the generators say so.
This is not an OAuth server
Sign, verify, rotate, publish. Grants, consent, PKCE and the endpoints around them are an application's business — they are where the product is, and a framework shipping them would be shipping opinions about a product.
Reading what a PHP application encrypted
APP_CIPHER=php
Not a preference. A ported database already holds columns that PHP wrote, and
they have to stay readable — so this reads and writes the exact wire format
PHP produced, against the same APP_KEY:
base64( json( { "iv": base64(iv), "value": base64(ciphertext), "mac": hex(mac) } ) )
"PHP" names the envelope, not a cipher — the cryptography underneath is
ordinary AES, and the two live in separate modules on purpose:
rainier_crypt::php::envelope is the codec (JSON, base64, hex, and which
bytes the MAC covers — no cryptography), rainier_crypt::php::primitive is
the raw-key AES-256-CBC / AES-256-GCM / HMAC (no encoding), and
PhpEncrypter is the thin composition holding the key ring and the write
selection. Raw keys are the compat rule: PHP feeds APP_KEY straight to
openssl_encrypt, unlike the native ciphers, which HKDF-derive
per-algorithm subkeys.
The default writes AES-256-CBC with an HMAC-SHA256 over the base64 forms of the IV and the ciphertext concatenated — not the raw bytes, which is the detail every reimplementation gets wrong and then cannot explain. The MAC is checked before decrypting, as the PHP implementation does: CBC without an authenticated MAC first is a padding oracle, and a padding oracle recovers plaintext without the key. Every failure returns one indistinguishable error for the same reason. Both layers are pinned in tests against an independent implementation rather than against themselves.
A PHP application configured for AES-256-GCM writes the other variant of
the same envelope — {iv, value, mac: "", tag} — and matching it is one
call:
PhpEncrypter::new(keys).writing(PhpCipher::Aes256Gcm)
Pick whatever the PHP application sharing the database is configured for: its
reader decrypts with its configured cipher rather than sniffing the
payload. Reading here is unaffected by the selection — the payload says which
variant it is, so a table that changed cipher generations ago opens end to
end, including rows from reimplementations that omitted the empty mac key
PHP writes beside a tag.
Use it to migrate, not to stay
Everything the native envelope has that this does not: a key id in the payload, so rotation is possible without re-encrypting; an algorithm name, so the cipher can change; AEAD, so there is one primitive to get right instead of two composed in the correct order.
Rotation still works — every key on the ring is tried, because the PHP payload names none — but that is a decrypt attempt per retired key rather than a lookup, which is worth knowing before keeping ten of them.
The shape that works is the same one legacy password hashes use: read with this, write with the native encrypter, and let the rows convert as they are touched.
Why it is not a Cipher variant
Cipher selects the AEAD inside Rainier's own envelope, which
names its algorithm, carries its key id and is URL-safe. The PHP format is a
different envelope with none of those properties. A variant would mean one
encrypter producing two incompatible payload shapes depending on a setting,
which is the ambiguity the self-describing envelope exists to remove.
APP_CIPHER is a closed set for the same reason a driver name is: writing the
wrong envelope is not a preference that degrades, it is a column nothing can
read.
This is not password hashing
Passwords are hashed, never encrypted. That lives in
Hasher — one-way, salted, deliberately slow.
If you can get the value back out, it is the wrong tool for a password. The type
signature makes the mistake hard: hash returns a String you cannot recover
the input from, which is the point.
| Reversible | Use for | |
|---|---|---|
Hasher | no | passwords |
encrypt | yes | data you must read back |
sign | n/a — nothing hidden | integrity of a visible value |
Where to use it
An encrypted column. Encrypt in the constructor, decrypt in an accessor —
and remember the model must not derive Serialize if the plaintext would leak
through it.
impl Account {
pub fn new(number: &str, crypt: &Encryption) -> Result<Self> {
Ok(Self { number_sealed: crypt.encrypt(number)? })
}
pub fn number(&self, crypt: &Encryption) -> Result<String> {
crypt.decrypt(&self.number_sealed)
}
}
A signed link. No database row, no expiry table:
let token = Crypt::instance().sign(&format!("{}", user.id))?;
let url = Url::instance().absolute("unsubscribe", &[("token", &token)])?;
Note there is no expiry in that — a signature is valid forever unless you build one in. Include a timestamp in the value and check it, or keep a revocation list, if the link should stop working.
Not sessions. The session store keeps data server-side and the cookie holds only an unguessable id, so there is nothing in the cookie to encrypt.
Public-key cryptography
Everything above is symmetric: the same key encrypts and decrypts, signs and verifies. Sometimes that is exactly wrong — anyone who can verify can also forge, and anyone who can decrypt can also encrypt.
| Writer needs | Reader needs | |
|---|---|---|
HmacSigner | the shared key | the shared key |
Ed25519Signer | the signing key | the public key |
AeadEncrypter | the shared key | the shared key |
SealedBox | the public key | the secret key |
Ed25519 signatures
Same wire shape as the HMAC signer, so the two are interchangeable at a call site. The difference is who can check one:
use rainier_framework::crypt::{Ed25519Signer, Signer, SigningKeyPair};
let keys = SigningKeyPair::generate();
let signer = Ed25519Signer::new(keys.clone());
let signed = signer.sign("licence-42")?;
// A service holding only the public key can verify, and cannot sign.
let checker = Ed25519Signer::verify_only(keys.public());
assert_eq!(checker.verify(&signed)?, "licence-42");
assert!(checker.sign("forged").is_err());
That is the shape for a licence key, a webhook you sign for someone else to check, or a token one service issues and several verify — none of them needs your secret.
Rotation is on the verifying side:
Ed25519Signer::new(current).trusting(previous.public())
Sealed boxes
Anonymous public-key encryption — libsodium's crypto_box_seal. Anyone with
a public key can seal a message to it; only the holder can open it.
use rainier_framework::crypt::{BoxKeyPair, SealedBox};
let recipient = BoxKeyPair::generate();
// The sender needs only the public key.
let sealed = SealedBox::new().seal(&recipient.public(), b"a secret report")?;
assert_eq!(SealedBox::new().unseal(&recipient, &sealed)?, b"a secret report");
A throwaway keypair is generated per message and its secret dropped, so the sender cannot reopen it either. And the sender is not authenticated: a sealed box says nothing about who wrote it. Sign the plaintext as well if you need to know.
Right for one-directional secrets — a client reporting to a server it cannot be given a shared key for, an offline machine encrypting to a key held elsewhere, a bug report containing a token.
Key agreement
let alice = BoxKeyPair::generate();
let bob = BoxKeyPair::generate();
// Both sides compute the same key from their own secret and the other's public.
assert_eq!(alice.agree(&bob.public()).bytes(), bob.agree(&alice.public()).bytes());
The result is already run through HKDF, so it is usable with any Cipher.
Raw X25519 output is not uniformly random and must not be used as a key
directly — that is the classic mistake, and this API does not let you make it.
Your own primitive
Implement Encrypter or Signer — it is two methods and one respectively:
pub struct KmsEncrypter { /* … */ }
impl Encrypter for KmsEncrypter {
fn encrypt_bytes(&self, plain: &[u8]) -> Result<String> { … }
fn decrypt_bytes(&self, payload: &str) -> Result<Vec<u8>> { … }
}
Rainier::new(".").with_encryption(Encryption::new(
Arc::new(KmsEncrypter::new(client)),
Arc::new(HmacSigner::new(keys)),
))
The typed helpers (encrypt, decrypt, encrypt_json) come from
EncrypterExt, a blanket-implemented extension trait, so you get them for free
and the port stays object-safe.
Testing
use rainier_framework::crypt::{Encryption, Key, KeyRing};
let crypt = Encryption::from_keys(KeyRing::new(Key::generate()));
No configuration and no environment. A fresh key per test is usually what you want — it also proves a test is not accidentally depending on a payload from another one.
To test rotation, construct the ring explicitly:
let old = Key::generate();
let written = Encryption::from_keys(KeyRing::new(old.clone())).encrypt("before")?;
let rotated = Encryption::from_keys(KeyRing::new(Key::generate()).with_previous(old));
assert_eq!(rotated.decrypt(&written)?, "before");
Operational notes
Debugis redacted.Keyprints its id and<redacted>;EncryptionprintsEncryption(..). A key in a log line is a key that has to be rotated.- Keep keys out of config.
APP_KEYis read directly by the framework and never stored in the config repository, so it cannot end up in aconfig.all()dump. .envis not a secret store. See Configuration.
This page is the framework’s docs/encryption.md at 7b8cb15.
Found a mistake? Edit it on GitHub.