Encryption
This page explains how Maskura’s envelope encryption works, from the key you hand the gateway to the bytes that leave your writer. It is the long-form companion to the post-quantum feature page and ADR 0011.
The model
Encryption is envelope encryption with a fresh data key per field:
- The gateway generates a random 256-bit data encryption key (DEK) and encrypts the field with AES-256-GCM.
- The DEK is then key-encapsulated to your public key, so only the holder of the matching private key can recover it.
- Both the ciphertext and the encapsulated DEK are written to storage.
Maskura does see the plaintext field transiently while the selected transform runs inside the per-session Wasm sandbox. It does not persist that plaintext as part of the encrypted output and never holds your private key. Decryption happens entirely on the client.
This is not end-to-end confidentiality against the gateway operator: the gateway must be trusted while it processes plaintext. Neither client-held keys nor Wasm isolation proves that an operator-controlled runtime did not copy that plaintext or that it ran the selected transform. Maskura does not currently provide execution attestation or computation proofs. See the operator trust boundary.
The primitives
| Role | Primitive | Why |
|---|---|---|
| Data cipher | AES-256-GCM | Authenticated encryption; 256-bit keys resist Grover (~2^128) |
| Classical KEM | X25519 (ECDH) | Protects against an undiscovered weakness in a young PQ scheme |
| Post-quantum KEM | ML-KEM-768 (FIPS 203) | Protects against a cryptographically relevant quantum computer |
| Combiner | HKDF-SHA256 | Folds the two shared secrets into one DEK |
The construction is a hybrid KEM: the DEK is secure unless both X25519 and ML-KEM-768 are broken. This is the consensus position of the IETF hybrid key-exchange draft, NSA CNSA 2.0, and BSI.
The data path contains no signatures, so ML-DSA and SLH-DSA are out of scope — KEM-only.
Encapsulation (encrypt)
For each detected field the gateway:
m = 32 random bytes from a CSPRNG
(mlkem_ss, mlkem_ct) = ML-KEM-768.Encaps(ek, m) # ek = your ML-KEM public key
x25519_sk = 32 random bytes
x25519_epk = X25519(x25519_sk, basepoint)
x25519_ss = X25519(x25519_sk, your_x25519_pk)
dek = HKDF-SHA256(ikm = x25519_ss ‖ mlkem_ss,
salt = "",
info = "maskura/hybrid/envelope-dek/v1",
L = 32)
enc_dek = x25519_epk ‖ mlkem_ct # 1120 bytes
field = AES-256-GCM(dek, iv, plaintext)
The wrapped key material (enc_dek) carries everything you need to decrypt:
your X25519 ephemeral public key and the ML-KEM-768 ciphertext. The DEK itself
is never stored — it is derived from the two shared secrets on both sides.
Decapsulation (decrypt, client-side)
(x25519_epk, mlkem_ct) = split(enc_dek)
x25519_ss = X25519(your_x25519_sk, x25519_epk)
mlkem_ss = ML-KEM-768.Decaps(your_mlkem_dk, mlkem_ct)
dek = HKDF-SHA256(same construction as above)
plaintext = AES-256-GCM.Decrypt(dek, iv, ct ‖ tag)
The envelope
Each encrypted field becomes a JSON object with five fields:
{"alg":"X25519+ML-KEM-768/AES-256-GCM","iv":"<b64>","enc_dek":"<b64>","ct":"<b64>","tag":"<b64>"}
algidentifies the construction. ExistingRSA-OAEP/AES-256-GCMenvelopes remain decryptable (dual-alg read); new writes are hybrid-only.ivis the 12-byte AES-GCM nonce.enc_dekis the 1120-byte hybrid encapsulation (base64).ct/tagare the AES-256-GCM ciphertext and authentication tag.
Key format
Keys are a single base64 blob of fixed-length concatenation, wrapped in PEM:
-----BEGIN MASKURA HYBRID PUBLIC KEY-----
base64( x25519_pk ‖ mlkem_ek ) # 32 + 1184 = 1216 bytes
-----END MASKURA HYBRID PUBLIC KEY-----
-----BEGIN MASKURA HYBRID PRIVATE KEY-----
base64( x25519_sk ‖ mlkem_seed ) # 32 + 64 = 96 bytes
-----END MASKURA HYBRID PRIVATE KEY-----
| Component | Size |
|---|---|
| X25519 public key / shared secret | 32 B |
| ML-KEM-768 encapsulation key (public) | 1184 B |
| ML-KEM-768 ciphertext | 1088 B |
| ML-KEM-768 shared secret | 32 B |
| ML-KEM-768 seed (private key serialization) | 64 B |
| Hybrid public key | 1216 B |
| Hybrid private key | 96 B |
enc_dek | 1120 B |
The gateway’s public-key-pem config field is unchanged: it is still a string.
The private key never touches the gateway.
Client tooling status
Current gateway releases accept only MASKURA HYBRID PUBLIC KEY PEM blocks for
new encrypted writes. MaskuraClient.generate_keypair() in Python and
MaskuraClient.generateKeypair() in TypeScript now create that exact hybrid
format. Their decrypt helpers decapsulate current hybrid envelopes locally and
also retain dual-algorithm read compatibility for old RSA envelopes. Explicit
generate_legacy_rsa_keypair() and generateLegacyRsaKeypair() helpers exist
only for pre-hybrid gateways and compatibility fixtures.
The CLI provides the same safe path: maskura key create --generate-encryption-key --private-key-out <path> attaches the public key to
the newly created API key and writes the private key locally with create-new
semantics (mode 0600 on Unix). maskura get ... --decrypt <path> decrypts
hybrid envelopes before writing the object to stdout. The private key is never
sent to Maskura.
Python hybrid support requires cryptography >= 47. TypeScript uses the Noble
X25519, HKDF-SHA256, and ML-KEM implementations while retaining CommonJS output.
SDK and CLI tests exercise the gateway wire sizes and round-trip behavior. The
Python and TypeScript implementations have also been verified against the
deterministic envelope produced by the Rust gateway reference implementation.
Security properties
- IND-CCA2. Both KEMs are CCA-secure; the HKDF combiner produces an indistinguishable DEK unless both are broken.
- Per-field keys. A fresh DEK per field means a compromise of one DEK does not expose any other field.
- Authenticated encryption. AES-GCM binds the ciphertext to its tag; any tampering fails decryption.
- Fail closed. If key encapsulation or encryption fails, the field is redacted rather than emitted as plaintext.
- Single-alg write. New writes are hybrid-only; legacy RSA keys are rejected for new writes and remain only for decrypting previously written objects.
Cost
The hybrid wrap trades a slightly larger key encapsulation for materially cheaper CPU than the RSA-OAEP wrap it replaces:
- ~34M Wasm fuel per encrypted field (vs ~52M for RSA-2048 OAEP).
enc_dekgrows from 256 B (RSA-2048) to 1120 B per field.
Measured numbers and methodology are in Benchmarks.