Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · message by message One write and one read of an encrypted field Step through the write of one phone number and the read that follows. The key store only wraps and unwraps; the data never goes there.
One write and one read of an encrypted field, step 7 of 7. Messages so far: Application to Key store: wrap this DEK for row 4200; Key store to Application: wrapped DEK; Application to Database: kek id, wrapped DEK, data; Database to Application: the record; Key store to Application: DEK for row 4200.Applicationhas the dataKey storeholds the KEKDatabaseholds the recordwrap this DEK for row 4200wrapped DEKkek id, wrapped DEK, datathe recordDEK for row 4200

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.

A data key per record, wrapped by a KEK that cannot be exported JavaScript · envelope-js/envelope.mjs
// 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

  1. Question 1 of 2 What is stored beside the encrypted data of one record in an envelope design?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The KEK's id, the wrapped data key, its nonce, and the data with its nonce

    The wrapped key is safe to store beside the data because it is unusable without the KEK, which the key store holds. A read costs one unwrap.

  2. Question 2 of 2 Record 4200 is copied into the place of row 4201. Why does it not open there?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The table, column and row id are the associated data of both the data and the wrap, and row 4201 gives other bytes

    Putting the row's place into the associated data costs no storage and turns a copy-paste between rows into a refusal.

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

  1. Question 1 of 2 A dependency leaks logs that contain the KEK. Which response is enough?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Make a new KEK, re-wrap every DEK, then re-encrypt the data under fresh DEKs, and treat copies made earlier as exposed

    Rotation answers a calendar and changes the wrapping. A leaked KEK needs new DEKs, because the old DEKs are recoverable from every old wrap.

  2. Question 2 of 2 A customer's data key is destroyed to erase their records. What must also be true for that to be erasure?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No copy of the key survives anywhere, in a backup, a log or a cache

    Crypto-shredding moves the problem from many copies of the data to the copies of one small key, which is easier to track, but every copy has to go.

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:

A blind index: normalising, what it reveals and what shortening costs Python · blind-index-py/blind_index.py
"""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

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:

Interactive · answer and compare How do you search an encrypted column? Answer three questions about the column and the table. The table marks which ways fit and says what each one costs.
Must the database find a row by an exact value of this column?
Does the column hold few distinct values, say under a hundred (a state, a plan, a status)?
Will the table reach millions of rows?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to find a row by an encrypted value, against what decides between them
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

  1. One question A blind index is stored for a column with 8 states. What does someone with a copy of the table learn from it?

    Choose one answer.

    Show the answer: One question

    Answer: Which rows hold the same state and how many, the column's frequency table, without the names

    A reader can match the groups with public statistics, which is why a column with few values is not worth an index.

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 encrypted, masked and tokenised JavaScript · techniques-js/techniques.mjs
// 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

  1. One question Support staff must see the last two digits of a phone number on screen, and billing needs the full number. What fits?

    Choose one answer.

    Show the answer: One question

    Answer: Store the number encrypted, and mask it when it is displayed

    The three techniques make different promises. Encryption keeps the value for whoever holds the key, and masking is a display rule on top of it.

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.

  1. A field store with a data key per row and a rotating KEK To do
  2. 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 with rand(32), a data nonce with rand(12) and a wrap nonce with rand(12), in that order. The data is sealed under the data key with the associated data frame("v1", "data", table, column, row_id). The data key is sealed under the key in keks[kek_id] with the associated data frame("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, raises ValueError("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 same nonce and data bytes, a new kek_id, a new wrap nonce from rand(12) and the data key wrapped by the new KEK. It leaves the old record as it was, and raises ValueError when the new KEK is not in keks.
  • shred(keks, kek_id) destroys a KEK: it is no longer in keks, 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.

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, then casefold(). 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 first bits bits of HMAC-SHA-256(column_key, normalise(value)) as lower-case hex (bits // 4 characters). The column key is HMAC-SHA-256(root_key, b"blind-index v1 " + column.encode() + b"\x01"), so the same text gives another index in another column. bits must be a multiple of 4 from 8 to 256, or ValueError.
  • find(root_key, column, value, rows, bits) takes rows, 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 of value and whose normalised plaintext equals the normalised value, 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.

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.

AES Encryption Tool (Developer) Open the lab record printed at the end of the first program with its key, nonce, associated data and ciphertext, then change one byte of the associated data and watch the tool refuse.

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.

  1. Question 1 of 15 In envelope encryption, what does the key-encryption key encrypt?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: Only data keys

    The two layers are the point: the data key sees the data, and the key-encryption key sees only data keys.

  2. Question 2 of 15 A table has 2 million records. Each stored wrap takes 60 bytes. How many megabytes do you touch to re-wrap all of them? (1 MB is 1,000,000 bytes.)

    Type a number.

    A hint

    Multiply the records by the bytes of one wrap.

    Show the answer: Question 2 of 15

    Answer: 120 MB

    2,000,000 times 60 bytes is 120,000,000 bytes, 120 MB. Re-encrypting 8 KiB records would touch about 16,400 MB.

  3. Question 3 of 15 Which statement about a stolen KEK is right?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: It opens every record whose wrapped DEK the thief also holds

    That is why the KEK is the valuable key, and why it stays in a key store or an HSM.

  4. Question 4 of 15 A wrapped 32-byte data key is 48 bytes and it travels with a 12-byte nonce. How many bytes of key overhead does each record carry?

    Type a number.

    Show the answer: Question 4 of 15

    Answer: 60 bytes

    48 plus 12 is 60 bytes. The data adds its own 16-byte tag, and its 12-byte nonce is stored too.

  5. Question 5 of 15 Why is the KEK's id part of the wrap's associated data and not of the data's?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: So that a rotation changes only the wrap, and the data and its tag stay valid

    The wrap says which KEK made it, and the data says where it belongs. Keeping the two apart is what lets the data stay untouched.

  6. Question 6 of 15 Why does the program import the KEK as a key that is not extractable?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: The code can wrap and unwrap with it but cannot read its bytes, so a bug or an injection cannot leak them

    In production the KEK does not reach the application at all, because a KMS or HSM holds it. Non-extractable is the closest a Web Crypto program can come.

  7. Question 7 of 15 What does re-wrapping change in a record?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: The KEK id, the wrap nonce and the wrapped key; the data and its nonce stay as they were

    The stored change per record is 60 bytes, which is why a rotation of a very large table is a modest job.

  8. Question 8 of 15 After a rotation, when may the old KEK be destroyed?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: When no record or backup that you still need holds a wrap under it

    A retired KEK is decrypt-only, and its last day comes when the last wrap under it is gone.

  9. Question 9 of 15 Why does a data key for each customer make an erasure request cheap?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: Destroying their data key makes their records unreadable without rewriting the table, if no copy of the key remains

    Crypto-shredding trades many copies of data for the copies of one key, and it is only as good as the key's tracking.

  10. Question 10 of 15 Why normalise a value before computing its blind index?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: So that the same value typed with other capitals or spaces finds the stored index

    A search that misses because of a trailing space looks like missing data. Normalise the same way when storing and searching.

  11. Question 11 of 15 A column holds four plan names and a colleague proposes a blind index for it. What do you say?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: Four indexes are the column's frequency table, so filter in the application or do not make the column searchable

    The state column in the program, with 8 values, showed the same thing. An index belongs on a column with many values.

  12. Question 12 of 15 A blind index is cut to 16 bits in a table of 10,000,000 rows. About how many rows share a random row's index, itself included?

    Type a number.

    A hint

    One row plus the other 9,999,999 rows divided by 2 to the power 16.

    Show the answer: Question 12 of 15

    Answer: 154 rows (anything from 153 to 155 counts)

    1 plus 9,999,999 divided by 65,536 is about 153.6, so about 154. The application decrypts these rows and drops the ones that do not match.

  13. Question 13 of 15 Can a masked phone number, with its first digits replaced by X, be restored from the masked copy?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: No, the hidden digits are gone from it

    That is the use of masking: a display or log that never held the full value.

  14. Question 14 of 15 In tokenisation, what must you protect most?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: The vault, which holds every real value, with its backups and audit trail

    A vault moves the risk from many databases to one. That can be an improvement, if the vault is strong.

  15. Question 15 of 15 Which statement about Rule 6(1)(a) of India's Digital Personal Data Protection Rules, 2025 is right?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: It gives encryption, obfuscation, masking and virtual tokens as examples of appropriate security measures and prescribes no algorithm

    The rules set the expectation of reasonable safeguards, and the engineering choice stays with the organisation.

Interview questions

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. 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.

  2. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.