Frontend Labs
⋯

Chapter 9 — Data security in the cloud: KMS and envelope encryption

In one sentence

Cloud KMS gives you a hardware-backed root key that never leaves the service, and envelope encryption uses it to wrap per-object or per-tenant data keys so you get scalable encryption, fine-grained access control via key policy, and crypto-shredding for deletion.

Where we are

G1 covered AEAD and "encrypt at rest with AES-256". This chapter is how that actually works at cloud scale: the key hierarchy, who can decrypt (the most important access-control question for data), and the operations you must get right.

How it works

Key types:

   KMS KEY (CMK / "customer managed key")
     - lives in the KMS service, backed by FIPS 140-2/3 HSMs
     - the raw key material NEVER leaves KMS (unless you imported it)
     - you call KMS to Encrypt/Decrypt/GenerateDataKey; KMS enforces the
       KEY POLICY + grants + (AWS) the caller's IAM policy
     - symmetric (AES-256, the default) or asymmetric (RSA/ECC) or HMAC
   AWS-MANAGED KEY (`aws/s3`, `aws/rds`...) -- convenient, but you don't
     control the key policy -> use CUSTOMER-managed keys for anything
     sensitive so YOU decide who can decrypt.
   DATA KEY (DEK) -- an ordinary AES key used to encrypt actual data.
     Generated by KMS (`GenerateDataKey` returns plaintext DEK +
     KMS-encrypted DEK). Not stored in KMS.

Envelope encryption (the core pattern):

   ENCRYPT an object:
     1. app calls kms:GenerateDataKey(KeyId = tenant-42-CMK)
        -> { Plaintext: DEK, CiphertextBlob: EncryptedDEK }
     2. app encrypts the object with DEK using AES-GCM (a real AEAD lib,
        G1 Ch 15). 
     3. app stores: [ EncryptedDEK ] + [ nonce ] + [ ciphertext ] + [ tag ]
     4. app ZEROES the plaintext DEK from memory.
   DECRYPT:
     1. app calls kms:Decrypt(CiphertextBlob = EncryptedDEK)  -- KMS checks
        the key policy: is THIS principal allowed to use tenant-42-CMK?
     2. KMS returns the plaintext DEK.
     3. app decrypts the object, zeroes the DEK.

   WHY: one KMS call per object is cheap; you can cache/scope DEKs; the
   ACCESS DECISION ("can this principal read tenant 42's data?") is
   enforced by KMS on the key, centrally and audited (CloudTrail logs
   every Decrypt with the principal and the encryption context).

Encryption context — additional authenticated data (G1 Ch 15) passed to KMS, e.g. { "tenant": "42", "purpose": "invoice" }. It's logged, and it must match on decrypt, so it binds a ciphertext to its context and gives you fine-grained CloudTrail filtering and key-policy conditions (kms:EncryptionContext:tenant).

The key hierarchy for SecureShop:

   org-root CMK (in the security account, tight key policy)
     |  (not used directly for data; roots a small number of things)
   per-environment CMK (prod)          -- default EBS/S3/RDS encryption
   per-tenant CMK  (tenant-42-CMK ...) -- wraps that tenant's DEKs
     key policy: allow kms:Decrypt only to `data-service-role` AND only
     with EncryptionContext tenant=42; allow kms:GenerateDataKey similarly;
     deny everything else; admins can manage but NOT use.
   per-object DEK (ephemeral)          -- actual AES-GCM key, discarded

Crypto-shredding (deletion by key destruction): if tenant 42's data is encrypted only under DEKs wrapped by tenant-42-CMK, then kms:ScheduleKeyDeletion on that CMK (7-30 day window) renders every one of those objects permanently unrecoverable — a clean, auditable erasure primitive across S3, RDS snapshots, backups, and logs simultaneously (Part 7 Ch 46). Works only if the per-tenant key boundary is real and no plaintext copies exist elsewhere.

Storage-service specifics:

   S3   default encryption with a CUSTOMER CMK; enforce with a bucket
        policy `Deny s3:PutObject if s3:x-amz-server-side-encryption-aws-
        kms-key-id != <your CMK>`; Block Public Access at account + bucket;
        Object Lock (WORM) for logs/backups; versioning + MFA-delete for
        ransomware resilience.
   RDS/Aurora  encryption at rest must be set at CREATE time (can't add
        later without a snapshot/restore); use a customer CMK; block
        snapshot sharing via SCP; IAM database authentication where
        possible.
   EBS  enable "default encryption" account-wide with a customer CMK (SCP-
        enforced).
   Secrets Manager / Parameter Store  encrypt with a customer CMK; prefer
        dynamic secrets (G1 Ch 49).

Worked example

Per-tenant decryption denied by key policy even with a valid IAM role.

   `analytics-role` has IAM `kms:Decrypt` on "*" (over-broad, but bear
   with it) and `s3:GetObject` on `ss-exports/*`. It tries to read
   tenant-99's invoice.

   1. s3:GetObject succeeds (object bytes = EncryptedDEK + ciphertext).
   2. app (or S3, for SSE-KMS) calls kms:Decrypt on the EncryptedDEK,
      which is wrapped by `tenant-99-CMK`, with EncryptionContext
      {tenant:"99"}.
   3. `tenant-99-CMK` KEY POLICY says: allow kms:Decrypt only to
      `arn:aws:iam::ACCT:role/data-service-role` AND only when
      `kms:EncryptionContext:tenant == "99"`.
      `analytics-role` is not `data-service-role` -> KMS returns
      AccessDenied, regardless of the role's `kms:Decrypt: *` IAM policy
      (resource policy + IAM policy must BOTH allow for KMS).
   4. The object stays ciphertext. CloudTrail logs the denied Decrypt with
      principal `analytics-role`, key `tenant-99-CMK`, context tenant=99 ->
      a high-signal alert ("cross-tenant key access attempt").

   The KEY POLICY is the real tenant-isolation control for data at rest.
   IAM breadth didn't matter.

Real-world scenario: "encrypted" is meaningless without knowing the mode

   Adobe's 2013 breach exposed data for roughly 150 million accounts,
   including what Adobe described at the time as "encrypted" passwords
   -- but the implementation used a single, shared key with 3DES in
   ECB MODE (G1 Ch 14's exact ECB lesson) and NO SALT. Because ECB
   encrypts identical plaintext blocks to identical ciphertext blocks,
   researchers were able to GROUP ACCOUNTS BY IDENTICAL CIPHERTEXT --
   directly identifying which accounts shared the same password, even
   without ever recovering the plaintext -- and then used the
   (also-included) password HINTS, many of which effectively gave the
   password away ("password is a fish" -> "Trout"), to crack a large
   fraction of the dataset.
   THIS CHAPTER'S EXACT LESSON, IN ONE INCIDENT: "encrypted" told you
   almost nothing useful here. The actual security depended entirely on
   the MODE (ECB leaks patterns), the KEY MANAGEMENT (one key for 150
   million records means one compromise is total), and whether the field
   even belonged in the "encrypt for confidentiality" category at all
   (passwords should have been HASHED, G1 Ch 10, never encrypted --
   encryption implies someone, somewhere, can get the plaintext back).
   Ask, for any "encrypted" field you inherit: which mode, whose key,
   and why encryption rather than hashing.

Practice (60 min) — sandbox

  1. Create two customer CMKs (tenant-a, tenant-b) with tight key policies (usable only by a specific role, only with a matching tenant encryption context).
  2. Write a small script: GenerateDataKey under tenant-a, AES-GCM-encrypt a file with the plaintext DEK, store EncryptedDEK||nonce||ct||tag, zero the DEK. Then decrypt it back.
  3. Try to kms:Decrypt the tenant-a blob using a role only allowed on tenant-b — confirm AccessDenied. Try with the wrong encryption context — confirm denial.
  4. ScheduleKeyDeletion on tenant-a (min 7-day window; then CancelKeyDeletion before it fires so you don't lose the lab). Read the CloudTrail events.
  5. Enforce S3 SSE-KMS with a bucket policy that denies puts without your CMK id; try a plain PutObject and confirm denial.
  6. Check CloudTrail: every Decrypt/GenerateDataKey with principal, key, encryption context. This is your data-access audit trail.

Build it in Go (45 min) — envelope encryption with a local "KMS"

The practice above uses a real cloud KMS. This lab builds the same pattern locally, so every mechanism is visible: a KMS whose key material never leaves it, a key policy (caller + tenant), the encryption context bound as AES-GCM additional authenticated data, key versions for rotation, and crypto-shredding.

// envelope: Chapter 9's envelope encryption, end to end, with a tiny local
// "KMS" so you can run it without a cloud account. Everything a real KMS
// enforces is here in miniature: keys that never leave the service, a key
// policy, encryption context bound as AAD, rotation, and crypto-shredding.
//
//	go run ./envelope
package main

import (
	"bytes"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"maps"
	"slices"
	"strings"
)

// ---------------------------------------------------------------- the KMS --

type kmsKey struct {
	versions      [][]byte // key material; never returned to callers
	allowedCaller string   // the key policy, reduced to its essence
	tenant        string   // required encryption context
	deleted       bool
}

type KMS struct{ keys map[string]*kmsKey }

var errAccessDenied = errors.New("AccessDenied")

func (k *KMS) CreateKey(id, caller, tenant string) {
	k.keys[id] = &kmsKey{versions: [][]byte{random(32)}, allowedCaller: caller, tenant: tenant}
}

// authorize is the key policy: right caller AND matching encryption context.
func (k *KMS) authorize(id, caller string, ctx map[string]string) (*kmsKey, error) {
	key, ok := k.keys[id]
	if !ok || key.deleted {
		return nil, fmt.Errorf("key %s: not found or scheduled for deletion", id)
	}
	if caller != key.allowedCaller || ctx["tenant"] != key.tenant {
		fmt.Printf("    [audit] DENY  caller=%s key=%s context=%v\n", caller, id, ctx)
		return nil, errAccessDenied
	}
	fmt.Printf("    [audit] ALLOW caller=%s key=%s context=%v\n", caller, id, ctx)
	return key, nil
}

// GenerateDataKey returns a fresh DEK in plaintext AND wrapped (encrypted
// under the KMS key's newest version, with the context as AAD).
func (k *KMS) GenerateDataKey(id, caller string, ctx map[string]string) (dek, wrapped []byte, err error) {
	key, err := k.authorize(id, caller, ctx)
	if err != nil {
		return nil, nil, err
	}
	dek = random(32)
	v := len(key.versions) - 1
	wrapped = append([]byte{byte(v)}, seal(key.versions[v], dek, aad(ctx))...)
	return dek, wrapped, nil
}

func (k *KMS) Decrypt(id, caller string, wrapped []byte, ctx map[string]string) ([]byte, error) {
	key, err := k.authorize(id, caller, ctx)
	if err != nil {
		return nil, err
	}
	v := int(wrapped[0]) // which key version wrapped this DEK
	if v >= len(key.versions) {
		return nil, errors.New("unknown key version")
	}
	return open(key.versions[v], wrapped[1:], aad(ctx))
}

// Rotate adds a new key version. Old versions stay, so old data still decrypts.
func (k *KMS) Rotate(id string) { k.keys[id].versions = append(k.keys[id].versions, random(32)) }

// ScheduleDeletion = crypto-shredding: every DEK this key wrapped becomes useless.
func (k *KMS) ScheduleDeletion(id string) { k.keys[id].deleted = true; k.keys[id].versions = nil }

// ------------------------------------------------------------ the app side --

// blob is what gets stored: wrapped DEK + nonce + ciphertext + tag. The
// plaintext DEK is never stored.
type blob struct {
	KeyID      string
	WrappedDEK []byte
	Ciphertext []byte // nonce || ciphertext || tag
	Context    map[string]string
}

func encryptObject(kms *KMS, keyID, caller string, ctx map[string]string, plaintext []byte) (*blob, error) {
	dek, wrapped, err := kms.GenerateDataKey(keyID, caller, ctx)
	if err != nil {
		return nil, err
	}
	defer clear(dek) // best effort: zero the plaintext key when done
	return &blob{keyID, wrapped, seal(dek, plaintext, aad(ctx)), ctx}, nil
}

func decryptObject(kms *KMS, caller string, b *blob, ctx map[string]string) ([]byte, error) {
	dek, err := kms.Decrypt(b.KeyID, caller, b.WrappedDEK, ctx)
	if err != nil {
		return nil, err
	}
	defer clear(dek)
	return open(dek, b.Ciphertext, aad(ctx))
}

// ------------------------------------------------------------- primitives --

func seal(key, plaintext, aad []byte) []byte {
	block, _ := aes.NewCipher(key)
	gcm, _ := cipher.NewGCM(block)
	nonce := random(gcm.NonceSize()) // random 96-bit nonce: fine for < 2^32 messages per key
	return gcm.Seal(nonce, nonce, plaintext, aad)
}

func open(key, sealed, aad []byte) ([]byte, error) {
	block, _ := aes.NewCipher(key)
	gcm, _ := cipher.NewGCM(block)
	if len(sealed) < gcm.NonceSize() {
		return nil, errors.New("ciphertext too short")
	}
	return gcm.Open(nil, sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():], aad)
}

// aad turns the encryption context into stable bytes (sorted keys).
func aad(ctx map[string]string) []byte {
	keys := slices.Sorted(maps.Keys(ctx))
	ordered := make([][2]string, len(keys))
	for i, k := range keys {
		ordered[i] = [2]string{k, ctx[k]}
	}
	b, _ := json.Marshal(ordered)
	return b
}

func random(n int) []byte {
	b := make([]byte, n)
	rand.Read(b)
	return b
}

// ------------------------------------------------------------------- demo --

func main() {
	kms := &KMS{keys: map[string]*kmsKey{}}
	kms.CreateKey("tenant-42-key", "data-service", "42")
	kms.CreateKey("tenant-99-key", "data-service", "99")
	invoice := []byte(`{"tenant":42,"total":"129.00","card":"**** 4242"}`)
	ctx42 := map[string]string{"tenant": "42", "purpose": "invoice"}

	step := func(s string) { fmt.Println("\n" + s) }

	step("1. encrypt tenant 42's invoice")
	b, err := encryptObject(kms, "tenant-42-key", "data-service", ctx42, invoice)
	must(err)
	fmt.Printf("    stored: wrappedDEK=%d bytes, ciphertext=%d bytes, plaintext DEK stored: no\n",
		len(b.WrappedDEK), len(b.Ciphertext))

	step("2. decrypt as data-service with the right context")
	pt, err := decryptObject(kms, "data-service", b, ctx42)
	fmt.Printf("    -> %s (err=%v)\n", pt, err)

	step("3. analytics-service tries the same blob (has the bytes, not the key policy)")
	_, err = decryptObject(kms, "analytics-service", b, ctx42)
	fmt.Println("    ->", err)

	step("4. data-service presents the blob with tenant 99's context")
	_, err = decryptObject(kms, "data-service", b, map[string]string{"tenant": "99", "purpose": "invoice"})
	fmt.Println("    ->", err)

	step("5. right caller and tenant, but a different purpose: the AAD no longer matches")
	_, err = decryptObject(kms, "data-service", b, map[string]string{"tenant": "42", "purpose": "export"})
	fmt.Println("    ->", err)

	step("6. tamper with one ciphertext byte")
	tampered := *b
	tampered.Ciphertext = bytes.Clone(b.Ciphertext)
	tampered.Ciphertext[20] ^= 1
	_, err = decryptObject(kms, "data-service", &tampered, ctx42)
	fmt.Println("    ->", err)

	step("7. rotate the KMS key; old data still decrypts, new data uses v2")
	kms.Rotate("tenant-42-key")
	b2, _ := encryptObject(kms, "tenant-42-key", "data-service", ctx42, []byte("new invoice"))
	fmt.Printf("    old blob wrapped with key v%d, new blob with key v%d\n", b.WrappedDEK[0]+1, b2.WrappedDEK[0]+1)
	pt, err = decryptObject(kms, "data-service", b, ctx42)
	fmt.Printf("    old blob -> %s (err=%v)\n", strings.TrimSpace(string(pt[:20])), err)

	step("8. tenant 42 leaves: crypto-shred by deleting their key")
	kms.ScheduleDeletion("tenant-42-key")
	_, err = decryptObject(kms, "data-service", b, ctx42)
	fmt.Println("    ->", err, "\n    every copy of every blob (backups, replicas, exports) is now unreadable")
}

func must(err error) {
	if err != nil {
		panic(err)
	}
}
$ go run ./envelope
1. encrypt tenant 42's invoice
    [audit] ALLOW caller=data-service key=tenant-42-key context=map[purpose:invoice tenant:42]
    stored: wrappedDEK=61 bytes, ciphertext=77 bytes, plaintext DEK stored: no
2. decrypt as data-service with the right context
    -> {"tenant":42,"total":"129.00","card":"**** 4242"} (err=<nil>)
3. analytics-service tries the same blob (has the bytes, not the key policy)
    [audit] DENY  caller=analytics-service key=tenant-42-key ...
    -> AccessDenied
4. data-service presents the blob with tenant 99's context
    -> AccessDenied
5. right caller and tenant, but a different purpose: the AAD no longer matches
    [audit] ALLOW caller=data-service key=tenant-42-key context=map[purpose:export tenant:42]
    -> cipher: message authentication failed
6. tamper with one ciphertext byte
    -> cipher: message authentication failed
7. rotate the KMS key; old data still decrypts, new data uses v2
    old blob wrapped with key v1, new blob with key v2
    old blob -> {"tenant":42,"total" (err=<nil>)
8. tenant 42 leaves: crypto-shred by deleting their key
    -> key tenant-42-key: not found or scheduled for deletion

What to notice:

  • Step 3 is the worked example above, executed. Having the ciphertext bytes (an S3 read) is worthless without the key policy.
  • Step 5 passed the policy check and still failed. The encryption context is authenticated data on the wrapped data key, so a ciphertext encrypted for purpose:invoice can't be unwrapped as purpose:export, even by an authorised caller. That's the "binds a ciphertext to its context" property.
  • Rotation keeps old versions. The wrapped key records which key version sealed it (v1), so rotating never breaks old data. Re-wrapping old data keys under v2 is a separate, optional job. Note that the bulk data isn't re-encrypted.
  • Use the AWS Encryption SDK or Google Tink in production. This lab is for understanding; those libraries also handle key caching, the message format, and algorithm agility.

Exercises:

  1. Add a tokenization vault (Chapter 43): replace card numbers with random tokens and keep the mapping encrypted under its own key.
  2. Add per-object data-key caching with a maximum number of uses and a maximum age, then count KMS calls before and after.
  3. Write a re-wrap job that moves every stored data key to the newest key version without touching the ciphertexts.

Common mistakes

  • AWS-managed keys for sensitive data. You don't control the key policy, so you can't answer "who can decrypt this?" precisely, and you can't crypto-shred. Use customer-managed CMKs.
  • kms:Decrypt on Resource: "*" in IAM. Even though the key policy is the real gate, broad kms:* is a privesc/exfil amplifier. Scope it.
  • No encryption context. You lose the binding between ciphertext and its tenant/purpose, and the fine-grained key-policy conditions and CloudTrail filters that go with it.
  • Assuming "encrypted at rest" means protected from the app. If the app can always call Decrypt, at-rest encryption only protects against stolen disks/snapshots and mis-shared backups — real, but not "the app is compromised" protection. Per-tenant keys + tight policies narrow it.
  • Forgetting RDS encryption is create-time. Retrofitting means snapshot + copy-with-encryption + restore.
  • Crypto-shredding without a real key boundary. If a plaintext copy exists in a search index, a cache, or a log, destroying the CMK doesn't erase it (Ch 46).

Check yourself

  1. What never leaves a KMS service, and what does that buy you?
  2. Walk through envelope encryption for one object, encrypt and decrypt.
  3. What is an encryption context and give two things it enables.
  4. For KMS, which policies must allow a Decrypt — and which is the real tenant-isolation control for data at rest?
  5. What is crypto-shredding and what must be true for it to actually erase data?
  6. Why can't you simply "turn on encryption" for an existing unencrypted RDS instance?

Further reading

  • Docs: AWS KMS "concepts", "key policies", "encryption context", and the AWS Encryption SDK (does envelope encryption + context correctly for you — use it rather than hand-rolling); GCP Cloud KMS + "Cloud EKM"; Azure Key Vault
    • Managed HSM.
  • Book: Practical Cloud Security (2nd ed.), Ch. on data protection; Serious Cryptography (2nd ed.) for the primitives underneath (G1 further reading).
  • Whitepaper: AWS "Logical separation" and "KMS cryptographic details"; Google "Encryption at rest in Google Cloud".
  • Pattern: "Crypto-shredding" write-ups (e.g. the GDPR-deletion-with-KMS posts); AWS "per-tenant isolation" SaaS lens.

Generated from real-life-security-guide-v1.md · 8 Oct 2026 16:36