Packages

Authenticated, non-deterministic encryption for Ecto fields

Current section

Files

Jump to
Raw

FORMAT.md

# Ciphertext format
This file defines the bytes stored by `Encrypted` version 1. Treat the
format, context packing, and key derivation as one compatibility contract.
## Envelope
Version 1 uses this layout:
```text
<<version::8, key_id::8, iv::binary-size(12),
tag::binary-size(16), ciphertext::binary>>
```
| Offset | Size | Encoding | Meaning |
|---:|---:|---|---|
| 0 | 1 byte | unsigned integer | version; must equal `1` |
| 1 | 1 byte | unsigned integer | master-key ID from `0` through `255` |
| 2 | 12 bytes | raw bytes | AES-GCM IV |
| 14 | 16 bytes | raw bytes | AES-GCM authentication tag |
| 30 | remaining bytes | raw bytes | AES-256-GCM ciphertext |
The minimum envelope is 30 bytes and represents an encrypted empty binary. The
ciphertext segment has the same byte length as the plaintext.
The version byte stays first in every format. A reader must reject an unknown
version. It must not interpret the remaining bytes using the version 1 layout.
## Context packing
The format packs a list of binary items without delimiters:
```text
item_count := unsigned little-endian 32-bit integer
item := byte_length || bytes
byte_length := unsigned little-endian 64-bit integer
packed(parts) := item_count || item_1 || ... || item_n
```
Version 1 context uses:
```text
packed([
"ecto_encrypted/aad/v1",
table,
storage_field
])
```
Lengths count bytes, not Unicode code points or graphemes. Table and field
values are converted through `String.Chars` and must not be empty.
The complete AES-GCM associated data is:
```text
<<version, key_id>> || packed_context
```
The cleartext header lets a reader choose a format and key. Including those two
bytes in the associated data prevents an attacker from changing either choice
without invalidating the authentication tag.
The context does not include a primary key. A valid ciphertext can therefore
move between rows in the same table and column. Including row identity would
make inserts, primary-key changes, bulk loading, and rotation depend on data
that `Ecto.ParameterizedType` does not receive.
## Per-field key derivation
The master key is 32 raw bytes. Configuration may express it as 64 hexadecimal
digits; decoding occurs before derivation.
Version 1 derives a 32-byte field key with RFC 5869 HKDF-SHA-384:
```text
salt = SHA-384("ecto_encrypted/hkdf-salt/v1")
info = packed([
"ecto_encrypted/field-key/v1",
table,
storage_field
])
PRK = HMAC-SHA-384(salt, master_key)
T1 = HMAC-SHA-384(PRK, info || 0x01)
field_key = first_32_bytes(T1)
```
The encryption domains differ from the domains used by `ecto_blind_search`.
Applications must still use separate master keys so that the two systems can
rotate independently.
## Encryption
Version 1 uses only AES-256-GCM:
```text
ciphertext, tag = AES-256-GCM-ENCRYPT(
field_key,
12_random_bytes,
plaintext,
complete_associated_data,
tag_length = 16
)
```
OTP's `:crypto.crypto_one_time_aead/7` performs encryption and decryption. The
package does not implement AES, GCM, SHA-384, or HMAC.
Version 1 uses the random-IV construction defined by
[NIST SP 800-38D, Section 8.2.2](https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-38d.pdf).
Section 8.3 limits authenticated-encryption invocations to `2^32` for one
derived field key across all application instances. The format contains no
invocation counter. An operator must rotate to different master-key material
before one table-and-storage-column context reaches the limit. A new key ID
backed by unchanged master-key bytes derives the same field key and does not
reset the count.
## Pinned vector
Inputs:
```text
master_key =
0000000000000000000000000000000000000000000000000000000000000000
table = "users"
storage_field = "email"
key_id = 7
iv = 101112131415161718191a1b
plaintext = 766563746f7220706c61696e7465787400
```
Derived values:
```text
field_key =
71edfd3a21c5850a0a0f6d95afc6432901bad2c04520e56c4ba2b99c709aa035
packed_context =
0300000015000000000000006563746f5f656e637279707465642f6161642f7631
050000000000000075736572730500000000000000656d61696c
tag =
1141bae68176d4be3989ff3a87af42c2
ciphertext =
71ae086004c63111b82f7fa1968a47159c
```
Complete envelope:
```text
0107101112131415161718191a1b1141bae68176d4be3989ff3a87af42c2
71ae086004c63111b82f7fa1968a47159c
```
The test suite checks this complete envelope and flips every byte in turn. Each
modified envelope must fail before returning plaintext.
## Version changes
Create a new envelope version before changing any of these values:
- cipher or mode;
- version or key-ID width;
- IV or tag size;
- envelope field order;
- associated-data bytes or packing;
- context fields;
- key derivation, salt, domain, hash, or output length; or
- compression policy.
Do not make a reader infer a format from length or successful decryption.