Skip to content
About
Main content

Envelope encryption and crypto-shredding in 40 lines

Measure your machineNeeds Python with cryptography

Problem

encrypts data with a and stores that key only wrapped (encrypted) by a master key held in a key service. Bulk happens locally at memory speed; the key service is asked only to or unwrap a 32-byte key. Deleting a record's data key makes its ciphertext unreadable everywhere, including in backups of the data: that is .

Build the mechanism and see deletion by key destruction. A small key service holds one master key and exposes wrap and unwrap. Each of 5 fake patients gets a random 32-byte data key and AES-GCM ciphertext; the store keeps the ciphertext and the wrapped key together. Time 100 MB of local encryption against one key wrap, erase patient 3 by deleting its wrapped key, then restore a backup of the store.

The question. How fast is local encryption compared with a key wrap, and does a backup undo the shredding?


Predict

After patient 3's wrapped key is deleted, can restoring a backup of the key store bring the records back?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    envelope-encryption-and-crypto-shredding-in.py120 linesDownload
    View the code
    envelope-encryption-and-crypto-shredding-in.py
    # /// script
    # requires-python = ">=3.10"
    # dependencies = ["cryptography"]
    # ///
    # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT
    # https://thedataengineering.com/lab-code/LICENSE.txt
    """Envelope encryption and crypto-shredding in 40 lines.
    
    Build the envelope encryption mechanism, measure its performance, and see data deletion by key destruction, including the conflict with backups."""
    
    import argparse
    import os
    import time
    import json
    import copy
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    
    class KeyService:
        def __init__(self):
            self._master_key = AESGCM.generate_key(bit_length=256)
            self._aesgcm = AESGCM(self._master_key)
    
        def wrap(self, data_key):
            nonce = os.urandom(12)
            return nonce + self._aesgcm.encrypt(nonce, data_key, None)
    
        def unwrap(self, wrapped_key):
            nonce, ciphertext = wrapped_key[:12], wrapped_key[12:]
            return self._aesgcm.decrypt(nonce, ciphertext, None)
    
    def main():
        parser = argparse.ArgumentParser(description=__doc__.split('\n')[0])
        parser.add_argument("--patients", type=int, default=5, help="Number of fake patients to generate.")
        parser.add_argument("--data-mb", type=int, default=100, help="Megabytes of data for the performance test.")
        parser.add_argument("--json", type=str, help="Also write the results to this JSON file.")
        args = parser.parse_args()
    
        results = {"summary": {}, "tables": {"operations": []}}
        ks = KeyService()
        db = {}
        
        print(f"--- 1. Encrypting {args.patients} Patients ---")
        for patient_id in range(1, args.patients + 1):
            data_key = AESGCM.generate_key(bit_length=256)
            wrapped_key = ks.wrap(data_key)
            
            aesgcm = AESGCM(data_key)
            nonce = os.urandom(12)
            data = f"Sensitive medical records for patient {patient_id}".encode()
            ciphertext = nonce + aesgcm.encrypt(nonce, data, None)
            
            db[patient_id] = {'wrapped_key': wrapped_key, 'ciphertext': ciphertext}
        print("Patient data encrypted and stored.")
    
        print(f"\n--- 2. Performance Comparison ({args.data_mb} MB) ---")
        data_key_local = AESGCM.generate_key(bit_length=256)
        aesgcm_local = AESGCM(data_key_local)
        data_bulk = os.urandom(args.data_mb * 1024 * 1024)
        nonce_bulk = os.urandom(12)
        
        t0 = time.time()
        aesgcm_local.encrypt(nonce_bulk, data_bulk, None)
        t1 = time.time()
        bulk_time = t1 - t0
        
        t2 = time.time()
        ks.wrap(data_key_local)
        t3 = time.time()
        wrap_time = t3 - t2
        
        print(f"Encrypting {args.data_mb} MB locally took: {bulk_time:.4f} seconds")
        print(f"Wrapping a 32-byte key once took: {wrap_time:.6f} seconds")
        
        results["summary"]["bulk_encrypt_time_s"] = bulk_time
        results["summary"]["key_wrap_time_s"] = wrap_time
    
        print("\n--- 3. Crypto-Shredding Patient 3 ---")
        # FIX: Use deepcopy so the backup actually retains its own independent wrapped_key
        backup_db = copy.deepcopy(db) 
        
        # "Erase" patient 3 by deleting only its wrapped key from the active DB
        del db[3]['wrapped_key']
        print("Patient 3's wrapped key deleted from active database.")
        
        try:
            ks.unwrap(db[3]['wrapped_key'])
            shredded = False
        except KeyError:
            print("Success: Active wrapped key is gone. Ciphertext is shredded.")
            shredded = True
            
        results["summary"]["active_db_shredded"] = shredded
    
        print("\n--- 4. The Backup Conflict ---")
        # The backup still has the key because of deepcopy
        try:
            recovered_key = ks.unwrap(backup_db[3]['wrapped_key'])
            backup_aesgcm = AESGCM(recovered_key)
            nonce = backup_db[3]['ciphertext'][:12]
            ciphertext = backup_db[3]['ciphertext'][12:]
            recovered_data = backup_aesgcm.decrypt(nonce, ciphertext, None)
            print(f"Data recovered from backup: {recovered_data.decode()}")
            backup_works = True
        except Exception as e:
            print(f"Failed to recover from backup: {e}")
            backup_works = False
    
        results["summary"]["backup_restore_works"] = backup_works
        
        results["tables"]["operations"].append({"op": "bulk_encrypt", "time_s": bulk_time})
        results["tables"]["operations"].append({"op": "key_wrap", "time_s": wrap_time})
        results["tables"]["operations"].append({"op": "shredding", "success": shredded})
        results["tables"]["operations"].append({"op": "backup_restore", "success": backup_works})
    
        if args.json:
            with open(args.json, "w") as f:
                json.dump(results, f, indent=2)
    
    if __name__ == "__main__":
        main()
  3. 3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.

    Terminal
    uv run envelope-encryption-and-crypto-shredding-in.py
    uv run envelope-encryption-and-crypto-shredding-in.py --help     # every knob
Knobs
Knob · defaultWhat it changes
--patients5 Number of fake patients to generate.
--data-mb100 Megabytes of data for the performance test.

Our run

Operations

Operations
OperationTimeSucceeded
bulk_encrypt23.9 ms—
key_wrap13.8 µs—
shredding—yes
backup_restore—yes
Reference runApple M2 · 8 cores · 8 GB
Output · uv run envelope-encryption-and-crypto-shredding-in.py
--- 1. Encrypting 5 Patients ---
Patient data encrypted and stored.

--- 2. Performance Comparison (100 MB) ---
Encrypting 100 MB locally took: 0.0239 seconds
Wrapping a 32-byte key once took: 0.000014 seconds

--- 3. Crypto-Shredding Patient 3 ---
Patient 3's wrapped key deleted from active database.
Success: Active wrapped key is gone. Ciphertext is shredded.

--- 4. The Backup Conflict ---
Data recovered from backup: Sensitive medical records for patient 3
macOS 26.6.2 · Python 3.13.3 · cryptography 50.0.2 · run 2026-10-20

What we found

Encrypting 100 MB locally took 23.9 ms, gigabytes per second and far above the chapter's floor; wrapping one key took 13.8 µs. Deleting patient 3's wrapped key made its ciphertext unreadable in the live store (shredded: yes). The backup still held the old wrapped key, and restoring from it decrypted patient 3's records again (backup restore worked: yes).

Here the key service is in the same process, so the wrap costs microseconds; a real key service adds a network round trip per call, which is why the data key is wrapped once and not per row.


Read it

Envelope encryption puts the cost where it is cheap: bulk data at local speed, one key-service call per data key. Crypto-shredding works only if every copy of the wrapped key goes too. A backup of the key store that can be restored is exactly the copy that undoes it, so a system must choose between keys that can be recovered and deletion that can be proved, and design its backups for that choice.