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
- Create two customer CMKs (
tenant-a,tenant-b) with tight key policies (usable only by a specific role, only with a matchingtenantencryption context). - Write a small script:
GenerateDataKeyundertenant-a, AES-GCM-encrypt a file with the plaintext DEK, storeEncryptedDEK||nonce||ct||tag, zero the DEK. Then decrypt it back. - Try to
kms:Decryptthetenant-ablob using a role only allowed ontenant-b— confirmAccessDenied. Try with the wrong encryption context — confirm denial. ScheduleKeyDeletionontenant-a(min 7-day window; thenCancelKeyDeletionbefore it fires so you don't lose the lab). Read the CloudTrail events.- Enforce S3 SSE-KMS with a bucket policy that denies puts without your CMK id;
try a plain
PutObjectand confirm denial. - Check CloudTrail: every
Decrypt/GenerateDataKeywith 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:invoicecan't be unwrapped aspurpose: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:
- Add a tokenization vault (Chapter 43): replace card numbers with random tokens and keep the mapping encrypted under its own key.
- Add per-object data-key caching with a maximum number of uses and a maximum age, then count KMS calls before and after.
- 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:DecryptonResource: "*"in IAM. Even though the key policy is the real gate, broadkms:*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
- What never leaves a KMS service, and what does that buy you?
- Walk through envelope encryption for one object, encrypt and decrypt.
- What is an encryption context and give two things it enables.
- For KMS, which policies must allow a
Decrypt— and which is the real tenant-isolation control for data at rest? - What is crypto-shredding and what must be true for it to actually erase data?
- 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.