Encryption

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.

HiddenTamper-evidentFor
encryptyesyesanything the client must not read
signnoyesanything 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.

CipherIdNonceNotes
XChaCha20Poly1305xc20p192-bitthe default
ChaCha20Poly1305c20p96-bitas used by TLS
Aes256Gcma256gcm96-bitfor interop, or hardware AES
Aes128Gcma128gcm96-bitshorter key, same material
Aes256GcmSiva256siv96-bitnonce-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", &current)?)
    .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.

ReversibleUse for
Hashernopasswords
encryptyesdata you must read back
signn/a — nothing hiddenintegrity 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 needsReader needs
HmacSignerthe shared keythe shared key
Ed25519Signerthe signing keythe public key
AeadEncrypterthe shared keythe shared key
SealedBoxthe public keythe 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

  • Debug is redacted. Key prints its id and <redacted>; Encryption prints Encryption(..). A key in a log line is a key that has to be rotated.
  • Keep keys out of config. APP_KEY is read directly by the framework and never stored in the config repository, so it cannot end up in a config.all() dump.
  • .env is not a secret store. See Configuration.

This page is the framework’s docs/encryption.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.