Applied Cryptography for Developers Module 9 – Data at rest and key management
Envelope encryption and data at rest
Give each record a data key, wrap it with a key-encryption key, rotate by re-wrapping, search with blind indexes and compare masking, tokens and encryption.
What you will learn
- Encrypt records with a fresh data key each, wrapped by a key-encryption key and bound to the row
- Rotate a key-encryption key by re-wrapping data keys without re-encrypting any data
- Decide when a leaked key-encryption key needs fresh data keys and not only a new wrapping
- Weigh blind indexes and deterministic encryption for searchable fields by what each reveals
- Explain how encryption, masking and tokenisation differ, including the safeguards of India's DPDP Rules
Before you start
What you’ll learn · Before you start
What you will learn
- Encrypt records with a fresh data key each, wrapped by a key-encryption key and bound to the row
- Rotate a key-encryption key by re-wrapping data keys without re-encrypting any data
- Decide when a leaked key-encryption key needs fresh data keys and not only a new wrapping
- Weigh blind indexes and deterministic encryption for searchable fields by what each reveals
- Explain how encryption, masking and tokenisation differ, including the safeguards of India's DPDP Rules
Before you start
On this page
A table of 10 million customer records is encrypted under one key, and that key has to change. Re-encrypting records of 4 KiB touches 41 GB; wrapping each record’s own key under a new key-encryption key touches 600 MB, because a stored wrapped key is 60 bytes.
This lesson builds that second design, envelope encryption: Encrypting data with a data key and then encrypting that key under a key-encryption key. When the top-level key changes, only the small wrapped keys have to be processed, not the data., with Web Crypto: a fresh data key for every record, wrapped by a key-encryption key that the code can use but cannot export. It rotates that key by re-wrapping, shows when that is not enough, weighs the ways to search encrypted fields, and compares encrypting a value with masking and tokenising it, including what India’s DPDP Rules say.
One key for a whole table makes every rotation a rewrite
With a single key, three things follow from the size of the table. Rotating the key means reading, decrypting and writing back every record. One theft of the key opens every record. And erasing one customer’s data on request means rewriting the table, or hoping that the backups age out.
Envelope encryption splits the job in two. A data encryption key (DEK): The key that encrypts the data itself, usually one for each record or file. It is stored only in wrapped form beside the data, and used once or a few times. (DEK) encrypts one record, and a key-encryption key (KEK): The key that wraps data keys. It stays in a key store or an HSM, where code can use it but not read it, and it is rotated by re-wrapping the data keys it protects. (KEK) encrypts only DEKs. Google’s key management documentation advises making a new DEK for every write, so that DEKs never need rotating, and keeping KEKs in one central key store. The AWS documentation describes the same pattern and adds that, when a key changes, you re-encrypt only the data keys and not the data. The numbers come from this lesson’s program:
| Situation | One key for the table | A DEK for each record, wrapped by a KEK |
|---|---|---|
| Rotate the top-level key | Read, decrypt and write back 41 GB | Re-wrap 10 million keys of 60 bytes, 600 MB |
| A key is stolen | The one key opens every record | A stolen DEK opens one record; a stolen KEK opens every record whose wrapped DEK the thief also holds |
| Erase one customer’s data | Rewrite the records | Destroy that record’s wrapped key, and every copy of the plain one |
| AES-GCM with random 96-bit nonces | At most 2³² records under the key (NIST SP 800-38D) | One record under each key |
The second row is the one to remember. A KEK is the more valuable key, and a later section shows exactly what its theft opens.
A data key for each record, wrapped by a key-encryption key
A write has four steps. The application makes a DEK for this record and encrypts the data with it, using AES-GCM. It sends the DEK to the key store, which seals it under the KEK and returns the key wrapping: Encrypting one key under another so that it can be stored or sent. AES key wrap (RFC 3394) needs no nonce; AES-GCM adds associated data that ties the wrapped key to its record. key. The application then stores the KEK’s id, the wrapped DEK, the nonce and the data. A read reverses it, and one call to the key store serves one record. Step through a write and a read:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A fresh data key. The application makes a new 256-bit data key (DEK) for this one record, on its own, with the system's random generator. No other record will use it.
- Seal the data. It encrypts the phone number with AES-GCM under the DEK, with the table, the column and the row id as associated data. The result is the data and its tag.
- Wrap the key. The application sends the DEK to the key store, with the KEK's id and the row as context. The key store seals it under the KEK and returns the wrapped key. The KEK never leaves the key store.
- The wrapped key comes back. The wrapped DEK is 48 bytes with a 12-byte nonce. Without the KEK it is unusable, so it can be stored next to the data.
- Store the record. The database receives the KEK's id, the wrapped DEK, the nonce and the data. It sees no plain key and no plain phone number.
- A read. Later the application reads the record and sends the wrapped DEK to the key store, with the same context. The key store opens it only if the KEK, the row and the tag all match.
- Unwrap, decrypt, forget. The key store returns the DEK, the application decrypts the data with the row as associated data, uses the phone number and drops the DEK. One call to the key store served one record.
The program does this with Web Crypto. The DEK is an AES-256-GCM key made for the record. The KEK is imported as a key that is not extractable, so the code can wrap and unwrap with it, and exportKey throws. In production the KEK lives in a KMS or an HSM and never reaches the application at all. The format has two choices that matter. The row, that is the table, the column and the id, goes into the associated data of the data and of the wrap, so a record opens only where it belongs. And the KEK’s id goes into the wrap’s associated data only, so that a rotation never has to touch the data. The program also checks the AES key wrap of RFC 3394 against its published vector, and plays out a rotation, a leaked KEK and a destroyed key, which the next section reads.
// Envelope encryption with Web Crypto: a fresh data key (DEK) for every record, wrapped with AES-GCM by a key-encryption
// key (KEK) that this code can use but not read. The record is bound to its row, the KEK is rotated by re-wrapping the
// DEKs, the AES key wrap of RFC 3394 is checked against its published vector, and a KEK is "destroyed". The KEK bytes
// are lab values; in production they live in a KMS or an HSM. Everything printed is true on every run, whatever the
// random keys and nonces.
const subtle = crypto.subtle;
const utf8 = new TextEncoder();
const hex = (buf) => Buffer.from(buf).toString('hex');
const same = (a, b) => Buffer.from(a).equals(Buffer.from(b));
const frame = (...parts) => Buffer.concat(parts.flatMap((p) => [Buffer.from([0, utf8.encode(p).length]), utf8.encode(p)]));
// A KEK is an AES-GCM key that is not extractable: this code can wrap and unwrap with it, and cannot export it.
const importKek = (bytes) => subtle.importKey('raw', bytes, 'AES-GCM', false, ['wrapKey', 'unwrapKey']);
const keks = new Map([['kek-1', await importKek(Uint8Array.from({ length: 32 }, (_, i) => i))]]);
// The data is bound to its row (table, column, id). The wrapped DEK is bound to the same row and to the KEK that wrapped
// it. The KEK's id is not in the data's associated data, so a rotation never has to touch the data.
const dataAad = (table, column, id) => frame('v1', 'data', table, column, id);
const wrapAad = (kekId, table, column, id) => frame('v1', 'wrap', kekId, table, column, id);
async function wrap(kekId, dek, table, column, id) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const wrapped = await subtle.wrapKey('raw', dek, keks.get(kekId), { name: 'AES-GCM', iv, additionalData: wrapAad(kekId, table, column, id) });
return { kekId, iv, wrapped };
}
async function unwrap(w, table, column, id, extractable) {
return subtle.unwrapKey('raw', w.wrapped, keks.get(w.kekId), { name: 'AES-GCM', iv: w.iv, additionalData: wrapAad(w.kekId, table, column, id) }, { name: 'AES-GCM' }, extractable, ['encrypt', 'decrypt']);
}
async function seal(kekId, table, column, id, text) {
const dek = await subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const nonce = crypto.getRandomValues(new Uint8Array(12));
const data = await subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData: dataAad(table, column, id) }, dek, utf8.encode(text));
return { w: await wrap(kekId, dek, table, column, id), nonce, data };
}
async function open(rec, table, column, id) {
try {
const dek = await unwrap(rec.w, table, column, id, false);
return new TextDecoder().decode(await subtle.decrypt({ name: 'AES-GCM', iv: rec.nonce, additionalData: dataAad(table, column, id) }, dek, rec.data));
} catch {
return null;
}
}
const phones = ['98450 11122', '99001 23456', '90350 77889'];
const rows = await Promise.all(phones.map((p, i) => seal('kek-1', 'customers', 'phone', String(4200 + i), p)));
const opened = await Promise.all(rows.map((r, i) => open(r, 'customers', 'phone', String(4200 + i))));
console.log(`3 records, a fresh DEK each, all open: ${opened.every((t, i) => t === phones[i])}`);
console.log(`sizes: wrapped DEK ${rows[0].w.wrapped.byteLength} bytes (32 key + 16 tag) and a ${rows[0].w.iv.length}-byte nonce, data ${rows[0].data.byteLength} bytes (${utf8.encode(phones[0]).length} + 16 tag)`);
console.log(`record 4200 opened as row 4201: ${await open(rows[0], 'customers', 'phone', '4201')}, as column email: ${await open(rows[0], 'customers', 'email', '4200')}`);
try {
await subtle.exportKey('raw', keks.get('kek-1'));
} catch (e) {
console.log(`export the KEK: ${e.name}`);
}
// Rotation: unwrap each DEK with the old KEK and wrap it with the new one. The data and its nonce are not touched.
keks.set('kek-2', await importKek(Uint8Array.from({ length: 32 }, (_, i) => 255 - i)));
async function rewrap(rec, to, table, column, id) {
const dek = await unwrap(rec.w, table, column, id, true);
return { ...rec, w: await wrap(to, dek, table, column, id) };
}
const rotated = await Promise.all(rows.map((r, i) => rewrap(r, 'kek-2', 'customers', 'phone', String(4200 + i))));
const afterRotation = await Promise.all(rotated.map((r, i) => open(r, 'customers', 'phone', String(4200 + i))));
console.log(`\nrotation to kek-2: data bytes unchanged ${rotated.every((r, i) => same(r.data, rows[i].data))}, wrapped DEKs changed ${rotated.every((r, i) => !same(r.w.wrapped, rows[i].w.wrapped))}, all open under kek-2: ${afterRotation.every((t, i) => t === phones[i])}`);
const records = 10_000_000;
const size = 4096;
console.log(`10 million records of 4 KiB: re-encrypting touches ${((records * size) / 1e9).toFixed(1)} GB, re-wrapping touches ${((records * 60) / 1e6).toFixed(0)} MB of wrapped keys`);
// A KEK that leaked is a different case. Rotation changed the wrapping, not the DEKs: an old backup holds the old wrapped DEK,
// and with the leaked kek-1 it unwraps the very DEK that still protects today's data. Only fresh DEKs close that door.
const oldDek = await unwrap(rows[0].w, 'customers', 'phone', '4200', false);
const readWithOldDek = async (rec) => {
try {
return new TextDecoder().decode(await subtle.decrypt({ name: 'AES-GCM', iv: rec.nonce, additionalData: dataAad('customers', 'phone', '4200') }, oldDek, rec.data));
} catch {
return null;
}
};
console.log(`after the rotation, the old wrapped DEK from a backup and the leaked kek-1 still read today's data: ${(await readWithOldDek(rotated[0])) === phones[0]}`);
const reencrypted = await seal('kek-2', 'customers', 'phone', '4200', phones[0]);
console.log(`after re-encrypting under a fresh DEK: ${(await readWithOldDek(reencrypted)) === phones[0]}, and the new record opens under kek-2: ${(await open(reencrypted, 'customers', 'phone', '4200')) === phones[0]}`);
// The AES key wrap of RFC 3394, section 4.1, which has no associated data and a fixed integrity value: 128-bit KEK, 128-bit key.
const rfcKek = await subtle.importKey('raw', Buffer.from('000102030405060708090A0B0C0D0E0F', 'hex'), 'AES-KW', false, ['wrapKey', 'unwrapKey']);
const rfcData = await subtle.importKey('raw', Buffer.from('00112233445566778899AABBCCDDEEFF', 'hex'), 'AES-GCM', true, ['encrypt']);
const rfcWrapped = hex(await subtle.wrapKey('raw', rfcData, rfcKek, 'AES-KW')).toUpperCase();
console.log(`\nRFC 3394 section 4.1: ${rfcWrapped.slice(0, 16)}…${rfcWrapped.slice(-8)}, ${rfcWrapped.length / 2} bytes for a 16-byte key, as published: ${rfcWrapped === '1FA68B0A8112B447AEF34BD8FB5A7B829D3E862371D2CFE5'}`);
// Destroying a KEK makes every DEK under it unusable, and so every record. A copy of the key store undoes that.
const backup = new Map(keks);
keks.delete('kek-2');
const afterDestroy = await Promise.all(rotated.map((r, i) => open(r, 'customers', 'phone', String(4200 + i)).catch(() => null)));
console.log(`\nkek-2 destroyed: ${afterDestroy.filter(Boolean).length} of 3 records open`);
keks.set('kek-2', backup.get('kek-2'));
const afterRestore = await Promise.all(rotated.map((r, i) => open(r, 'customers', 'phone', String(4200 + i))));
console.log(`the key store restored from a backup: ${afterRestore.filter(Boolean).length} of 3 records open again`);
// A lab record with fixed values, to open in the AES tool of this site: a key of the bytes 0 to 31, a nonce of twelve 0x0b
// bytes, and the row as associated data.
const labBytes = Uint8Array.from({ length: 32 }, (_, i) => i);
const labKey = await subtle.importKey('raw', labBytes, 'AES-GCM', false, ['encrypt']);
const labNonce = new Uint8Array(12).fill(0x0b);
const labAad = dataAad('customers', 'phone', '4200');
const labSealed = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv: labNonce, additionalData: labAad }, labKey, utf8.encode('98450 11122')));
console.log('\nlab record (AES-256-GCM, every value in hex):');
console.log(` key ${hex(labBytes)}`);
console.log(` nonce ${hex(labNonce)}`);
console.log(` AAD ${hex(labAad)}`);
console.log(` ciphertext ${hex(labSealed.subarray(0, -16))}`);
console.log(` tag ${hex(labSealed.subarray(-16))}`); Output
3 records, a fresh DEK each, all open: true sizes: wrapped DEK 48 bytes (32 key + 16 tag) and a 12-byte nonce, data 27 bytes (11 + 16 tag) record 4200 opened as row 4201: null, as column email: null export the KEK: InvalidAccessError rotation to kek-2: data bytes unchanged true, wrapped DEKs changed true, all open under kek-2: true 10 million records of 4 KiB: re-encrypting touches 41.0 GB, re-wrapping touches 600 MB of wrapped keys after the rotation, the old wrapped DEK from a backup and the leaked kek-1 still read today's data: true after re-encrypting under a fresh DEK: false, and the new record opens under kek-2: true RFC 3394 section 4.1: 1FA68B0A8112B447…71D2CFE5, 24 bytes for a 16-byte key, as published: true kek-2 destroyed: 0 of 3 records open the key store restored from a backup: 3 of 3 records open again lab record (AES-256-GCM, every value in hex): key 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f nonce 0b0b0b0b0b0b0b0b0b0b0b0b AAD 000276310004646174610009637573746f6d657273000570686f6e65000434323030 ciphertext 91b62694bf4cbc98eafb29 tag c555809b2764b937391069bee14681d6
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node envelope.mjs
Three records were sealed, each with its own DEK, and all three opened. A wrapped DEK is 48 bytes, 32 of key and 16 of tag, and it comes with a 12-byte nonce, so a record carries 60 bytes of key overhead beside its data and 16-byte tag. Record 4200 refused to open as row 4201 and as the email column, and the KEK could not be exported. AES key wrap, the other standard way, has no nonce and no associated data: it turned a 16-byte key into 24 bytes, exactly the value RFC 3394 section 4.1 publishes. With it, the binding to the row has to come from the data’s associated data alone, while a wrap with AES-GCM binds the key to its row as well. AWS documents that its symmetric operations use AES-256-GCM, and that the encryption context a caller supplies is used as associated data.
A plain data key beside the data
Wrong: keep the data key in the row “so that reads are faster”, or use one data key for many customers’ records. Right: store only the wrapped key, make a key for each record or at least for each customer, and let a read cost one unwrap. OWASP agrees that an encrypted DEK may sit beside the data, and asks that the KEK be held apart and be no weaker.
Key idea
Store only wrapped keys beside the data, bind every ciphertext and every wrap to its row, and keep the key-encryption key where code can use it but not read it.
Checkpoint
Check: A wrapped data key for each record
Rotating the key-encryption key re-wraps keys and leaves the data alone
To rotate, make a second KEK. For each record, unwrap its DEK with the old KEK and wrap it with the new one, writing a new nonce, a new wrapped key and the new KEK’s id. The data and its nonce stay as they were, which the program checks, and the records open under the new KEK. At 10 million records of 4 KiB, re-encrypting touches 41 GB and re-wrapping 600 MB. The old KEK stays available to unwrap until the last record is re-wrapped and the last backup that holds an old wrap has expired. NIST SP 800-57 names that span the recipient-usage period. For a key that wraps a very large number of keys in a short time it recommends an originator-usage period of about a day or a week, and for one that wraps few, up to two years, with the recipient period no more than three years longer. The KEK of this program wraps with AES-GCM and random nonces, so its wraps count against the same limit of 2³² as the table’s last row: far away at 10 million records, but a KEK that wraps billions of keys must be rotated before it gets there.
A KEK that has leaked is a different event, and the program plays it. Rotation changed the wrapping and not the DEKs. A backup holds the old wrapped DEK, and with the leaked old KEK it unwraps the very DEK that still protects today’s data, which the program shows reading the current record. Only fresh DEKs close that door: after the record was re-encrypted under a new DEK, the old one read nothing. The AWS documentation says the same of its own rotation: it neither rotates data keys nor re-encrypts data, and it does not help against a compromised data key. The decisions side by side:
| Event | What you do | Why |
|---|---|---|
| The calendar says rotate the KEK | Re-wrap every DEK under the new KEK | No key was exposed, and 600 MB beats 41 GB |
| The KEK may have leaked | New KEK, re-wrap, then re-encrypt the data under fresh DEKs; treat copies taken earlier as exposed | An old wrap and the leaked KEK still open today’s data |
| One DEK may have leaked | Re-encrypt the records it protects under a fresh DEK | A wrap does not protect a key that left in the clear |
| A customer asks for erasure | Destroy their DEK and every copy of it | Crypto-shredding works only if no copy of the key survives |
The last row is crypto-shredding: Deleting data by destroying the key that protects it. It works only if no copy of the key, and no copy of the plaintext, survives in backups, logs or caches., and the program tests its catch. With the KEK destroyed, 0 of 3 records opened. With the key store restored from a backup, 3 of 3 opened again. Destroying a key erases data only when every copy of the key and of the plaintext, in backups, logs and caches, goes too. The code lab at the end of this lesson writes seal_field, open_field, rotate_kek and shred against the same rules.
Key idea
Re-wrapping answers a calendar and re-encrypting answers a leak, so decide which event you are in before the job starts.
Checkpoint
Check: Rotating a KEK, and when that is not enough
Searching encrypted fields leaks something: blind indexes and deterministic encryption
SQL cannot search an encrypted column. Two designs bring back an exact-match lookup, and each pays in what a reader of the table learns. A blind index: An HMAC of a normalised value under a key of its own, stored beside an encrypted column so the database can find rows by exact value. Equal values give equal indexes, so the index shows which rows are equal. is a second column: an HMAC of the normalised value under a key of its own, which a search computes again and looks up. deterministic encryption: Authenticated encryption without a nonce, such as AES-SIV: the same value and associated data always seal to the same bytes. That allows exact-match lookups and shows which values are equal., such as the AES-SIV of RFC 5297, seals the value without a nonce, so equal values give equal ciphertexts and the column itself can be matched. In both, equal values show as equal. The program takes a table of 2,000 rows and measures what that costs:
"""Blind indexes for searching encrypted fields: an HMAC of the normalised value, under a key of its own for each column.
The program shows what a reader of the table learns from an index (the frequency table of the column), what shortening
the index costs in extra matches, and why the value is normalised first. The key and the 2,000 rows are lab values."""
import hmac
import random
import unicodedata
from collections import Counter
ROOT_KEY = bytes(range(32)) # a lab key; real keys live in a KMS and never in code
def column_key(column: str) -> bytes:
"""One key for each column: HKDF-Expand with a label, so the same text in two columns gives two indexes."""
return hmac.digest(ROOT_KEY, b"blind-index v1 " + column.encode() + b"\x01", "sha256")
def normalise(value: str) -> str:
return unicodedata.normalize("NFKC", value).strip().casefold()
def blind_index(column: str, value: str, bits: int = 256) -> str:
"""The first `bits` bits of HMAC-SHA-256(column key, normalised value), as hex."""
tag = int.from_bytes(hmac.digest(column_key(column), normalise(value).encode(), "sha256"), "big")
return f"{tag >> (256 - bits):0{(bits + 3) // 4}x}"
rng = random.Random(7)
STATES = ["Maharashtra", "Karnataka", "Tamil Nadu", "Delhi", "Gujarat", "Kerala", "Punjab", "Assam"]
WEIGHTS = [30, 22, 16, 12, 9, 6, 3, 2]
ROWS = [(f"user{n}.{rng.randrange(1000, 9999)}@example.com", rng.choices(STATES, WEIGHTS)[0]) for n in range(2000)]
EMAILS = [r[0] for r in ROWS]
STATE_COLUMN = [r[1] for r in ROWS]
# 1. Normalising before indexing, so that a search finds what was stored.
stored = blind_index("email", "priya.sharma@example.com")
typed = " Priya.Sharma@Example.COM "
print(f"typed {typed!r}: matches the stored index after normalising: {blind_index('email', typed) == stored}")
raw = hmac.digest(column_key("email"), typed.encode(), "sha256").hex()
print(f"the same text hashed as typed, without normalising, matches: {raw == stored}")
print(f"the same text in the phone column gives another index: {blind_index('phone', 'priya.sharma@example.com') != stored}")
# 2. What the index column shows a reader of the table: the plaintext column's frequency table.
index_counts = Counter(blind_index("state", s) for s in STATE_COLUMN)
plain_counts = Counter(STATE_COLUMN)
print(f"\nstate column: {len(plain_counts)} different values, {len(index_counts)} different indexes")
print(f"the largest groups of the index hold {[c for _, c in index_counts.most_common(3)]} rows; of the plain column, {[c for _, c in plain_counts.most_common(3)]}")
print(f"email column: {len(set(blind_index('email', e) for e in EMAILS))} different indexes for {len(set(EMAILS))} different emails, every group has one row")
# 3. Shortening the index: fewer bits put more rows in each group, so a search returns extras that the application drops.
print("\nindex bits rows a lookup returns (mean) groups")
for bits in (256, 32, 16, 8):
groups = Counter(blind_index("email", e, bits) for e in EMAILS)
mean = sum(c * c for c in groups.values()) / len(EMAILS)
print(f"{bits:>10} {mean:>27.2f} {len(groups):>6}")
for rows in (2_000, 10_000_000):
print(f"at 16 bits, {rows:>10,} rows: about {1 + (rows - 1) / 2**16:.1f} rows come back for a lookup") Output
typed ' Priya.Sharma@Example.COM ': matches the stored index after normalising: True
the same text hashed as typed, without normalising, matches: False
the same text in the phone column gives another index: True
state column: 8 different values, 8 different indexes
the largest groups of the index hold [610, 429, 323] rows; of the plain column, [610, 429, 323]
email column: 2000 different indexes for 2000 different emails, every group has one row
index bits rows a lookup returns (mean) groups
256 1.00 2000
32 1.00 2000
16 1.03 1969
8 8.72 256
at 16 bits, 2,000 rows: about 1.0 rows come back for a lookup
at 16 bits, 10,000,000 rows: about 153.6 rows come back for a lookup
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 blind_index.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Normalising first matters: the address typed with capitals and spaces found the stored index, and hashed as typed it did not. The same text in the phone column gave another index, because each column has its own key. The state column has 8 values and its index has 8 groups of 610, 429, 323 rows and so on, exactly the plain column’s frequency table, which a reader can compare with public statistics. The email column has one row in every group, so its index shows only that nothing repeats. Shortening the index gives each lookup extra rows: 1.03 rows per lookup at 16 bits in this table, 8.72 at 8 bits, and about 154 at 16 bits in a table of 10 million rows. The application decrypts those rows and drops the ones that do not match, which is what the second lab step does.
The index key is a secret of its own
Give each column its own index key, derived with a label as in the HKDF lesson, and store it like a KEK. A value with little entropy, such as a six-digit code with a million possibilities, can be enumerated by anyone who gets the index key and the table, so a leaked index key calls for the same response as a leaked KEK.
The choice depends on the column and the table. Answer three questions about yours:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way | Exact match in SQL | What a table reader learns | A column with few values | A table of 10 million rows | In Web Crypto alone |
|---|---|---|---|---|---|
| Blind index What is stored: an HMAC of the normalised value | Good an equality query on the stored index, with its own index in the database | Mixed which rows are equal: the run showed groups of 610, 429 and 323 for the state column | Poor 8 states made 8 indexes, so the index is the column's frequency table | Mixed a full index finds one row; at 16 bits about 154 rows of 10 million come back to filter | Good HMAC-SHA-256 is in Web Crypto and node:crypto |
| Deterministic AEAD What is stored: the value sealed without a nonce | Good equal values seal to equal bytes, so the column itself can be matched | Mixed which rows are equal, as with the blind index (RFC 5297 section 1.3.2 describes the effect) | Poor equal values are visible as equal ciphertexts, so the frequency table shows | Good an equality query on the column, with no second column | Poor AES-SIV is not in Web Crypto or in node:crypto of Node.js 24.21.0; a library has it |
| Decrypt and filter What is stored: nothing extra | Mixed works in the application after decrypting, not in the database | Good nothing beyond an encrypted column: no equal values show | Good nothing changes, because nothing is stored to compare | Poor a lookup decrypts every row: 10 million decryptions | Good AES-GCM is all it needs |
How the answers decide
- “Must the database find a row by an exact value of this column?” No, we only read a row we already have: rules out Blind index and Deterministic AEAD. Nothing needs an index, so storing one only adds what a table reader can learn. Encrypt the column alone.
- “Does the column hold few distinct values, say under a hundred (a state, a plan, a status)?” Yes, few values: rules out Blind index and Deterministic AEAD. With a handful of values, equal indexes or equal ciphertexts are the column's frequency table in clear. Filter in the application, or leave the column unsearchable.
- “Will the table reach millions of rows?” Yes: rules out Decrypt and filter. Decrypting every row for every lookup costs millions of decryptions a time; only an index or a deterministic column scales.
- “Must the database find a row by an exact value of this column?” Yes, by exact value and “Will the table reach millions of rows?” No, thousands: adds a caveat to Decrypt and filter. For a few thousand rows, decrypting and filtering in the application is slow but simple, and it leaks nothing extra.
In short: index only the columns you must search by exact value; never index a column with a handful of values; and decrypt-and-filter is a fair answer for a few thousand rows. These costs come from the measurements above and from RFC 5297’s account of what a repeated input reveals.
Checkpoint
Check: Searching encrypted fields
Encryption, masking and tokenisation make three different promises
Encryption turns a value into one that only a key undoes. masking: Hiding part of a value, for example showing only the last two digits of a number. The hidden part is gone from the masked copy, so it cannot be restored from it. removes part of the value for good. tokenisation: Replacing a value with a random token and keeping the value in a protected vault. The token has no mathematical relation to the value; only a lookup in the vault gives it back. swaps the value for a random token and keeps the value in a vault. The program applies all three to one phone number and prints what a copy of the application database alone would show, without the key or the vault:
// One phone number kept three ways: encrypted with AES-256-GCM, masked, and replaced by a vault token. For each the
// program prints what survives, who can undo it, and what a copy of the application database alone shows. The key and the
// numbers are lab values; everything printed is true on every run, whatever the random bytes.
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const phone = '98450 11122';
const numbers = [phone, ...Array.from({ length: 999 }, (_, i) => `9${String(8000 + ((i * 7919) % 2000)).padStart(4, '0')} ${String(10000 + ((i * 104729) % 90000))}`)];
// Encryption: reversible with the key, different every time, and as long as the value.
const key = Buffer.alloc(32, 7);
function seal(text) {
const nonce = randomBytes(12);
const c = createCipheriv('aes-256-gcm', key, nonce);
return Buffer.concat([nonce, c.update(text, 'utf8'), c.final(), c.getAuthTag()]);
}
function unseal(blob) {
const d = createDecipheriv('aes-256-gcm', key, blob.subarray(0, 12));
d.setAuthTag(blob.subarray(-16));
return Buffer.concat([d.update(blob.subarray(12, -16)), d.final()]).toString('utf8');
}
const a = seal(phone);
const b = seal(phone);
console.log(`encryption: ${a.length} bytes for a ${phone.length}-character value, opens with the key: ${unseal(a) === phone}, two encryptions of the same value differ: ${!a.equals(b)}`);
// Masking: part of the value is thrown away for good. Many numbers share a mask.
const mask = (p) => p.replace(/\d(?=.*\d{2}$)/g, 'X');
const masks = new Map();
for (const n of numbers) masks.set(mask(n), (masks.get(mask(n)) ?? 0) + 1);
console.log(`masking: ${phone} becomes ${mask(phone)}, of which 2 of 10 digits survive; ${numbers.length} numbers made ${masks.size} masks, up to ${Math.max(...masks.values())} numbers share one`);
// Tokenisation: the value goes into a vault and a random token takes its place; the token has no relation to the value.
const vault = new Map();
const byValue = new Map();
function tokenise(value) {
if (byValue.has(value)) return byValue.get(value);
const token = randomBytes(8).toString('hex');
vault.set(token, value);
byValue.set(value, token);
return token;
}
const t1 = tokenise(phone);
console.log(`tokenisation: ${phone} becomes a ${t1.length}-character random token, the vault gives the value back: ${vault.get(t1) === phone}, the same number gets the same token again: ${tokenise(phone) === t1}`);
console.log('\nwhat a copy of the application database shows, without the key or the vault:');
console.log(` encryption the length of each value, and nothing about equal values (every blob differs)`);
console.log(` masking the last 2 digits of every number, ${numbers.length} numbers in ${masks.size} groups`);
console.log(` token nothing about the number, but equal numbers have equal tokens (${new Set(numbers.map(tokenise)).size} tokens for ${new Set(numbers).size} numbers)`); Output
encryption: 39 bytes for a 11-character value, opens with the key: true, two encryptions of the same value differ: true masking: 98450 11122 becomes XXXXX XXX22, of which 2 of 10 digits survive; 1000 numbers made 100 masks, up to 11 numbers share one tokenisation: 98450 11122 becomes a 16-character random token, the vault gives the value back: true, the same number gets the same token again: true what a copy of the application database shows, without the key or the vault: encryption the length of each value, and nothing about equal values (every blob differs) masking the last 2 digits of every number, 1000 numbers in 100 groups token nothing about the number, but equal numbers have equal tokens (1000 tokens for 1000 numbers)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node techniques.mjs
| Question | Encryption | Masking | Tokenisation |
|---|---|---|---|
| Can the value come back? | Yes, with the key | No: the hidden digits are gone | Yes, from the vault |
| A copy of the database shows | The length of each value | The last 2 digits of each: 1,000 numbers fell into 100 groups | Nothing about the number, but equal numbers have equal tokens |
| Best for | Storing and returning the full value | Showing part of a value on a screen or in a log | Letting other systems carry a value around without holding it |
| What you must protect | The keys, which is this lesson | Nothing: but choose which digits to keep | The vault, its backups and its audit trail |
Encryption is the only one of the three that keeps the value and needs no second system besides the key store. Masking is a display decision, not storage, and a masked copy next to the unmasked original protects nothing. A token is only as safe as the vault behind it, and a vault holds every real value in one place.
What India's DPDP Rules ask for
Rule 6(1)(a) of the Digital Personal Data Protection Rules, 2025 gives encryption, obfuscation, masking and virtual tokens mapped to the data as examples of the appropriate security measures a data fiduciary takes. Rule 6(1)(c) asks for visibility on access through logs, monitoring and review, Rule 6(1)(d) for measures such as backups so that processing can continue, and Rule 6(1)(e) for keeping the logs and personal data needed to detect and investigate unauthorised access for one year, unless another law requires otherwise. Rule 1(4) brings Rule 6 into force eighteen months after the Rules were published in the Gazette. The list says “such as” and prescribes no algorithm: choose by the table above, and manage the keys as this lesson does.
Checkpoint
Check: Encryption, masking and tokenisation
Practise: a field store and a blind index
The lab below has two steps, each with sample tests that run in your browser. Step one is a store for one encrypted field per row: a data key for each row, wrapped by a KEK, with the rotation and the shredding of the third section. Step two is the blind index of the fourth section, with its normalising, its truncation and the filter that drops the rows that only share the short index:
Code lab · 2 steps
Build an encrypted field store
Write the sealing, opening, rotating and shredding of a field under an envelope of keys, then the index that finds a row by its encrypted value.
- A field store with a data key per row and a rotating KEK To do
- A blind index that normalises and survives truncation To do
Exercise · Hard · Python
A field store with a data key per row and a rotating KEK
Write the four functions of field_store.py, a store for one encrypted field per row with envelope encryption. A record is a dictionary with kek_id, wrap_nonce, wrapped, nonce and data. The cipher is given as aead, an object with aead.seal(key, nonce, plaintext, aad) and aead.open(key, nonce, sealed, aad); open raises ValueError when the key, the nonce, the associated data or a single byte is wrong. Python has no AES in its standard library, so the sample tests use a stand-in cipher built from HMAC-SHA-256; real code uses AES-256-GCM. rand(n) returns n random bytes. frame(*parts), in the starter code, joins texts with a two-byte length before each, so that two different places never give the same bytes.
seal_field(keks, kek_id, table, column, row_id, plaintext, aead, rand)makes a fresh 32-byte data key withrand(32), a data nonce withrand(12)and a wrap nonce withrand(12), in that order. The data is sealed under the data key with the associated dataframe("v1", "data", table, column, row_id). The data key is sealed under the key inkeks[kek_id]with the associated dataframe("v1", "wrap", kek_id, table, column, row_id). The KEK's id stays out of the data's associated data, so that a rotation never has to touch the data.open_field(keks, record, table, column, row_id, aead)returns the plaintext. Every failure, an unknown KEK, a changed byte, another row or another column, raisesValueError("cannot open"), the same message each time.rotate_kek(keks, record, new_kek_id, table, column, row_id, aead, rand)returns a new record: the samenonceanddatabytes, a newkek_id, a new wrap nonce fromrand(12)and the data key wrapped by the new KEK. It leaves the old record as it was, and raisesValueErrorwhen the new KEK is not inkeks.shred(keks, kek_id)destroys a KEK: it is no longer inkeks, so every record wrapped by it stops opening.
The starter code wraps nothing: it keeps the data key in the record, in the clear.
Starter code · field_store.py
def frame(*parts: str) -> bytes:
"""Each part after a two-byte length, so that ("ab", "c") and ("a", "bc") give different bytes."""
return b"".join(len(p.encode()).to_bytes(2, "big") + p.encode() for p in parts)
def seal_field(keks, kek_id, table, column, row_id, plaintext, aead, rand):
"""A record: a fresh data key wrapped by keks[kek_id], and the plaintext sealed under the data key."""
dek = rand(32)
nonce = rand(12)
data = aead.seal(dek, nonce, plaintext, frame("v1", "data", table, column, row_id))
return {"kek_id": kek_id, "wrap_nonce": b"", "wrapped": dek, "nonce": nonce, "data": data} # the key in the clear
def open_field(keks, record, table, column, row_id, aead):
"""The plaintext of a record; ValueError("cannot open") for any record that does not open here."""
return aead.open(record["wrapped"], record["nonce"], record["data"], frame("v1", "data", table, column, row_id))
def rotate_kek(keks, record, new_kek_id, table, column, row_id, aead, rand):
"""The record wrapped by another KEK; the data is not touched."""
return dict(record)
def shred(keks, kek_id):
"""Destroy a KEK.""" The sample tests · test_field_store.py
import hashlib
import hmac
from field_store import frame, open_field, rotate_kek, seal_field, shred
class StandIn:
"""A test-only AEAD built from HMAC-SHA-256: a keystream for secrecy and a 16-byte tag over everything for integrity."""
def _stream(self, key, nonce, n):
out, i = b"", 0
while len(out) < n:
out += hmac.digest(key, b"stream" + nonce + i.to_bytes(4, "big"), "sha256")
i += 1
return out[:n]
def _tag(self, key, nonce, aad, ct):
return hmac.digest(key, b"tag" + nonce + len(aad).to_bytes(4, "big") + aad + ct, "sha256")[:16]
def seal(self, key, nonce, plaintext, aad):
ct = bytes(a ^ b for a, b in zip(plaintext, self._stream(key, nonce, len(plaintext))))
return ct + self._tag(key, nonce, aad, ct)
def open(self, key, nonce, sealed, aad):
ct, tag = sealed[:-16], sealed[-16:]
if len(sealed) < 16 or not hmac.compare_digest(tag, self._tag(key, nonce, aad, ct)):
raise ValueError("bad tag")
return bytes(a ^ b for a, b in zip(ct, self._stream(key, nonce, len(ct))))
AEAD = StandIn()
def make_rand():
"""Repeatable 'random' bytes for the tests: each call continues a counter."""
state = {"i": 0}
def rand(n):
state["i"] += 1
return hashlib.sha256(b"test" + state["i"].to_bytes(4, "big")).digest()[:n].ljust(n, b"\x00")
return rand
def keks():
return {"kek-1": bytes([1] * 32), "kek-2": bytes([2] * 32)}
def error_of(call):
try:
call()
except ValueError as e:
return str(e)
return None
def test_frame_keeps_places_apart():
"""a length before each part"""
assert frame("ab", "c") != frame("a", "bc")
assert frame("v1", "x") == b"\x00\x02v1\x00\x01x"
def test_round_trip_and_the_data_key_is_wrapped():
"""the plaintext comes back, and the data key is nowhere in the record in the clear"""
ks, rand = keks(), make_rand()
rec = seal_field(ks, "kek-1", "customers", "phone", "4200", b"98450 11122", AEAD, rand)
assert open_field(ks, rec, "customers", "phone", "4200", AEAD) == b"98450 11122"
dek = make_rand()(32) # the first value rand gives is the data key
assert dek not in rec.values()
assert rec["kek_id"] == "kek-1"
assert len(rec["wrapped"]) == 32 + 16 and len(rec["nonce"]) == 12 and len(rec["wrap_nonce"]) == 12
def test_a_record_opens_only_in_its_place():
"""another row, another column, another table, a changed byte: the same error each time"""
ks, rand = keks(), make_rand()
rec = seal_field(ks, "kek-1", "customers", "phone", "4200", b"98450 11122", AEAD, rand)
for args in (("customers", "phone", "4201"), ("customers", "email", "4200"), ("orders", "phone", "4200")):
assert error_of(lambda a=args: open_field(ks, rec, *a, AEAD)) == "cannot open"
for field in ("data", "wrapped"):
bad = dict(rec, **{field: bytes([rec[field][0] ^ 1]) + rec[field][1:]})
assert error_of(lambda b=bad: open_field(ks, b, "customers", "phone", "4200", AEAD)) == "cannot open"
assert error_of(lambda: open_field({}, rec, "customers", "phone", "4200", AEAD)) == "cannot open"
def test_rotation_rewraps_and_leaves_the_data_alone():
"""same data bytes, same data nonce, a new wrap; it opens under kek-2 and the old record still opens under kek-1"""
ks, rand = keks(), make_rand()
rec = seal_field(ks, "kek-1", "customers", "phone", "4200", b"98450 11122", AEAD, rand)
new = rotate_kek(ks, rec, "kek-2", "customers", "phone", "4200", AEAD, rand)
assert new["data"] == rec["data"] and new["nonce"] == rec["nonce"]
assert new["kek_id"] == "kek-2" and new["wrapped"] != rec["wrapped"] and new["wrap_nonce"] != rec["wrap_nonce"]
assert open_field(ks, new, "customers", "phone", "4200", AEAD) == b"98450 11122"
assert open_field(ks, rec, "customers", "phone", "4200", AEAD) == b"98450 11122"
assert rec["kek_id"] == "kek-1"
assert error_of(lambda: open_field({"kek-1": ks["kek-1"]}, new, "customers", "phone", "4200", AEAD)) == "cannot open"
def test_rotation_needs_the_new_kek_and_the_right_row():
"""an unknown new KEK, or the wrong row, is an error and changes nothing"""
ks, rand = keks(), make_rand()
rec = seal_field(ks, "kek-1", "customers", "phone", "4200", b"98450 11122", AEAD, rand)
before = dict(rec)
assert error_of(lambda: rotate_kek(ks, rec, "kek-9", "customers", "phone", "4200", AEAD, rand)) is not None
assert error_of(lambda: rotate_kek(ks, rec, "kek-2", "customers", "phone", "4201", AEAD, rand)) is not None
assert rec == before
def test_shredding_a_kek_closes_its_records():
"""records under the destroyed KEK stop opening, records under the other KEK do not"""
ks, rand = keks(), make_rand()
a = seal_field(ks, "kek-1", "customers", "phone", "1", b"one", AEAD, rand)
b = seal_field(ks, "kek-2", "customers", "phone", "2", b"two", AEAD, rand)
shred(ks, "kek-1")
assert "kek-1" not in ks
assert error_of(lambda: open_field(ks, a, "customers", "phone", "1", AEAD)) == "cannot open"
assert open_field(ks, b, "customers", "phone", "2", AEAD) == b"two" A hint
Build the two associated-data values first, with frame, and keep them apart: "data" for the data, "wrap" plus the KEK's id for the wrapped key. In rotate_kek, open the old wrap with the old KEK and the old associated data, then seal the same data key again under the new KEK, the new id and a fresh wrap nonce; copy nonce and data as they are.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · Python
A blind index that normalises and survives truncation
Write the three functions of blind_index.py, the search side of an encrypted column: an index that lets the database find a row by value without holding the value.
normalise(value)returns the value in the form that is indexed: Unicode NFKC, white space cut from both ends, thencasefold(). Text typed as" Priya.Sharma@Example.COM "and as"priya.sharma@example.com"must index alike, and so must full-width letters such as"priya"and"priya".blind_index(root_key, column, value, bits=256)returns the firstbitsbits ofHMAC-SHA-256(column_key, normalise(value))as lower-case hex (bits // 4characters). The column key isHMAC-SHA-256(root_key, b"blind-index v1 " + column.encode() + b"\x01"), so the same text gives another index in another column.bitsmust be a multiple of 4 from 8 to 256, orValueError.find(root_key, column, value, rows, bits)takesrows, a list of(row_id, stored_index, plaintext), where the plaintext is what the application gets after decrypting that row. It returns the ids of rows whose stored index equals the index ofvalueand whose normalised plaintext equals the normalisedvalue, in order. A short index matches rows that only share its bits; the second test drops them.
The starter code hashes the value as typed, with the root key as it is, and returns every row that shares the index.
Starter code · blind_index.py
import hashlib
import hmac
import unicodedata
def normalise(value: str) -> str:
"""The form that is indexed: NFKC, white space trimmed, case folded."""
return value
def blind_index(root_key: bytes, column: str, value: str, bits: int = 256) -> str:
"""The first `bits` bits of HMAC-SHA-256 of the normalised value under the column's own key, as hex."""
return hashlib.sha256(value.encode()).hexdigest()
def find(root_key: bytes, column: str, value: str, rows, bits: int = 256) -> list:
"""Ids of the rows whose index and whose normalised plaintext both match the value."""
wanted = blind_index(root_key, column, value, bits)
return [row_id for row_id, stored, _plain in rows if stored == wanted] The sample tests · test_blind_index.py
import hmac
from collections import Counter
from blind_index import blind_index, find, normalise
ROOT = bytes(range(32)) # the lab key of the lesson's example
def error_of(call):
try:
call()
except ValueError as e:
return str(e)
return None
def test_normalise_gives_one_form():
"""case, white space and full-width letters"""
assert normalise(" Priya.Sharma@Example.COM ") == "priya.sharma@example.com"
assert normalise("priya") == "priya"
assert normalise("Straße") == "strasse"
def test_the_index_is_a_keyed_hash_of_the_normalised_value():
"""the lab values of the lesson's program: the same index for the same address however it was typed"""
column_key = hmac.digest(ROOT, b"blind-index v1 email\x01", "sha256")
expected = hmac.digest(column_key, b"priya.sharma@example.com", "sha256").hex()
assert blind_index(ROOT, "email", "priya.sharma@example.com") == expected
assert blind_index(ROOT, "email", " Priya.Sharma@Example.COM ") == expected
assert len(expected) == 64
def test_every_column_has_its_own_key():
"""the same text indexes differently in the email and the phone column, and under another root key"""
a = blind_index(ROOT, "email", "same text")
assert a != blind_index(ROOT, "phone", "same text")
assert a != blind_index(bytes(32), "email", "same text")
def test_a_short_index_is_the_start_of_the_long_one():
"""bits cuts the tag from the left"""
full = blind_index(ROOT, "email", "priya@example.com")
assert blind_index(ROOT, "email", "priya@example.com", 16) == full[:4]
assert blind_index(ROOT, "email", "priya@example.com", 32) == full[:8]
assert blind_index(ROOT, "email", "priya@example.com", 8) == full[:2]
def test_bits_must_be_a_multiple_of_four_from_8_to_256():
"""anything else is a ValueError"""
for bad in (0, 4, 7, 10, 260, -8):
assert error_of(lambda b=bad: blind_index(ROOT, "email", "x", b)) is not None
def test_find_drops_the_rows_that_only_share_the_short_index():
"""an 8-bit index matches several rows; the plaintext decides which one is meant"""
people = [f"user{n}@example.com" for n in range(60)]
rows = [(n, blind_index(ROOT, "email", p, 8), p) for n, p in enumerate(people)]
counts = Counter(stored for _, stored, _ in rows)
shared = next(n for n, stored, _ in rows if counts[stored] > 1) # a row whose index another row has too
target = people[shared]
assert counts[blind_index(ROOT, "email", target, 8)] > 1
assert find(ROOT, "email", target, rows, 8) == [shared]
assert find(ROOT, "email", " " + target.upper() + " ", rows, 8) == [shared]
assert find(ROOT, "email", "nobody@example.com", rows, 8) == []
def test_find_returns_every_equal_row_in_order():
"""two rows with the same address both come back"""
rows = [(1, blind_index(ROOT, "email", "a@example.com"), "a@example.com"), (2, blind_index(ROOT, "email", "b@example.com"), "b@example.com"), (3, blind_index(ROOT, "email", "A@example.com"), "A@example.com")]
assert find(ROOT, "email", "a@example.com", rows) == [1, 3] A hint
Compute the column key once per call. To keep the first bits bits of a 256-bit tag, turn the digest into an integer with int.from_bytes(tag, "big") and shift it right by 256 - bits, then format it in hex with bits // 4 digits (f"{n:0{bits // 4}x}"). In find, compare the stored index first and only then the normalised plaintext.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then a practice session covers all five ideas, starting with the one you answered least well, and gives the reason with every answer:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid · Warm-up
What is envelope encryption, and why does it make rotating the top-level key cheap?
Envelope encryption uses two layers of keys. A data encryption key (DEK), made fresh for each record or file, encrypts the data. A key-encryption key (KEK), kept in a KMS or an HSM, encrypts only the DEKs, and the wrapped DEK is stored beside the data. A read needs one call to the key store to unwrap one DEK. Rotating the top-level key then means unwrapping each DEK with the old KEK and wrapping it with the new one, and the data does not change: in the lesson's numbers, 10 million records of 4 KiB cost 41 GB to re-encrypt but 600 MB of wrapped keys to re-wrap. The catch is that re-wrapping fits a calendar rotation only. After a leak of the KEK, old wrapped copies still open the same DEKs, so the data needs fresh DEKs.
-
Mid to senior
Support staff must find a customer by exact phone number, and phone numbers are encrypted at rest. Compare a blind index with deterministic encryption for this column, say what someone with a copy of the table learns in each case, and describe the keys you would create.
-
Senior to staff
Your KEK was printed in a debug log that a log service kept for 30 days. Walk through the response: what you rotate, what you re-wrap, what you re-encrypt, how you scope the exposure, and what you do about backups.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What problem does envelope encryption solve?
With one key for a table, rotating it means rewriting every record (41 GB for 10 million records of 4 KiB), one theft opens everything, and erasing one customer's data means rewriting the table. Envelope encryption seals each record with its own data encryption key (DEK) and encrypts only DEKs with a key-encryption key (KEK): re-wrapping 10 million 60-byte keys touches 600 MB.
-
What are the steps of a write and a read in envelope encryption?
Write: make a DEK for this record and encrypt the data with it (AES-GCM); the key store seals the DEK under the KEK and returns the wrapped key; store the KEK's id, the wrapped DEK, the nonce and the data. Read: the reverse, with one call to the key store per record. The KEK should be a key that code can use but not export; in production it lives in a KMS or an HSM.
-
What goes into the associated data of an envelope-encrypted record?
The row (table, column and id) goes into the associated data of both the data and the wrap, so a record opens only where it belongs. The KEK's id goes into the wrap's associated data only, so that a rotation never has to touch the data.
-
After which events do you re-wrap keys, and after which do you re-encrypt data?
A calendar rotation: new KEK, re-wrap every DEK, leave the data. A leaked KEK: new KEK, re-wrap, then re-encrypt the data under fresh DEKs and treat earlier copies as exposed, because an old wrap plus the leaked KEK still opens today's data. A leaked DEK: re-encrypt the records it protects. The old KEK stays until the last record and the last backup holding an old wrap are done.
-
What is crypto-shredding, and what is its catch?
Erasing data by destroying the key that protects it: destroy a record's DEK and every copy of it. It works only if no copy of the key survives. With the KEK destroyed, 0 of 3 records opened; with the key store restored from a backup, 3 of 3 opened again. Copies of the plaintext in backups, logs and caches must go as well.
-
What does a blind index or deterministic encryption cost in what a table reader learns?
Equal values show as equal. A blind index stores, in an extra column, the HMAC of each normalised value, keyed with a key that belongs to that column alone. The 8-value state column's index gave exactly the plain column's frequency table, and a short index returns extra rows to decrypt and drop: 1.03 per lookup at 16 bits in 2,000 rows, about 154 in 10 million. Index only what must be searched by exact value.
-
How do encryption, masking and tokenisation differ?
Encryption: the value comes back with the key, a database copy shows each value's length, and you protect the keys. Masking removes part of the value for good, a display decision rather than storage: keeping the last 2 digits left 1,000 numbers in 100 groups. Tokenisation swaps the value for a random token and keeps it in a vault: equal numbers get equal tokens, and you protect the vault, its backups and its audit trail.
-
What does Rule 6 of India's DPDP Rules, 2025 say about encryption and logs?
Rule 6(1)(a) gives encryption, obfuscation, masking and virtual tokens mapped to the data as examples of appropriate security measures. Rule 6(1)(c) asks for visibility on access through logs, monitoring and review, and Rule 6(1)(e) for keeping the logs and personal data needed to detect and investigate unauthorised access for one year. The Rules prescribe no algorithm.
Key takeaways
- Envelope encryption gives each record its own data key and wraps that key under a key-encryption key, so rotating the top-level key touched 600 MB of wrapped keys instead of 41 GB of data.
- Bind every ciphertext and every wrap to its row with associated data, keep the KEK’s id out of the data’s associated data so that rotation never rewrites the data, and keep the KEK where code can use it but not export it.
- Re-wrapping fits a calendar; after a leaked KEK an old wrap plus the old KEK still opens today’s data, so the data needs fresh data keys, and crypto-shredding works only when no copy of a key survives.
- A blind index or deterministic encryption shows which rows are equal: it gave the frequency table of an 8-value column, and a short index returned about 154 rows per lookup at 10 million rows, so index only what must be searched.
- Encryption keeps and returns the value under a key, masking throws part of it away, and tokenisation moves it to a vault; India’s DPDP Rules name all three as examples of security measures and prescribe no algorithm.
Terms in this lesson
- blind index
- An HMAC of a normalised value under a key of its own, stored beside an encrypted column so the database can find rows by exact value. Equal values give equal indexes, so the index shows which rows are equal.
- crypto-shredding
- Deleting data by destroying the key that protects it. It works only if no copy of the key, and no copy of the plaintext, survives in backups, logs or caches.
- data encryption key (DEK)
- The key that encrypts the data itself, usually one for each record or file. It is stored only in wrapped form beside the data, and used once or a few times.
- deterministic encryption
- Authenticated encryption without a nonce, such as AES-SIV: the same value and associated data always seal to the same bytes. That allows exact-match lookups and shows which values are equal.
- envelope encryption
- Encrypting data with a data key and then encrypting that key under a key-encryption key. When the top-level key changes, only the small wrapped keys have to be processed, not the data.
- key wrapping
- Encrypting one key under another so that it can be stored or sent. AES key wrap (RFC 3394) needs no nonce; AES-GCM adds associated data that ties the wrapped key to its record.
- key-encryption key (KEK)
- The key that wraps data keys. It stays in a key store or an HSM, where code can use it but not read it, and it is rotated by re-wrapping the data keys it protects.
- masking
- Hiding part of a value, for example showing only the last two digits of a number. The hidden part is gone from the masked copy, so it cannot be restored from it.
- tokenisation
- Replacing a value with a random token and keeping the value in a protected vault. The token has no mathematical relation to the value; only a lookup in the vault gives it back.
References
- RFC 3394: Advanced Encryption Standard (AES) Key Wrap Algorithm (Internet Engineering Task Force (IETF))
- RFC 5297: Synthetic Initialization Vector (SIV) Authenticated Encryption Using AES (Internet Engineering Task Force (IETF))
- Cloud KMS documentation: Envelope encryption (Google Cloud)
- AWS KMS developer guide: Cryptography essentials (Amazon Web Services)
- AWS KMS developer guide: Rotate AWS KMS keys (Amazon Web Services)
- OWASP Cryptographic Storage Cheat Sheet (OWASP Foundation)
- SP 800-57 Part 1 Rev. 5: Recommendation for Key Management, General (section 5.3 on cryptoperiods) (National Institute of Standards and Technology (NIST))
- Digital Personal Data Protection Rules, 2025 (rules 1 and 6) (Ministry of Electronics and Information Technology, Government of India)
- Web Cryptography Level 2 (AES-GCM, AES-KW, HMAC) (World Wide Web Consortium (W3C))
- Node.js 24 documentation: Web Crypto API (OpenJS Foundation (Node.js))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress