Lesson 08 / 21
Encryption at Rest
Data at rest is compared across three options: no encryption, provider key, customer key. How many subjects can decrypt, whether key use lands in the audit log, and the overhead the customer key brings are counted with real envelope encryption; on key loss, 3,973 GB across 14 datasets becomes permanently unreadable.
Published Updated
In this lesson
- Counts the three encryption options along who can decrypt: subject, pair, widest grant
- Measures the cost of key rotation and key loss in envelope encryption with code that actually runs
- Names the path encryption does not close and counts the legitimate work that narrowing it stops
Contents
The previous topic wrote down who could do what: the subject–action–resource matrix was drawn, policy documents were evaluated, account boundaries were traced. That ledger skipped one question — when someone with access opens the data, what do they see? The access list says whose names open the door; it says nothing about what is inside the box behind it. This lesson compares three options for data at rest on a single measure: no encryption, provider key, customer key. The measure is how many subjects can decrypt how many datasets; two columns sit alongside it — whether key use shows up in the tenant’s audit log, and the overhead the customer key brings. Where encrypted secrets are kept and how the key sits there was measured under repository-based deployment; what is measured here is who holds the key when it sits inside a rented service.
The measurement runs on the regional measurement network and is fictional. The datasets,
subject catalog, and read counts are a model written with node; the encryption really runs
with node:crypto, and the key-derivation seeds and content values are made up.
CS1. Data at rest sits in 14 datasets: three environments, seven types (raw measurement, summary, invoice, archive, backup, snapshot, log archive). Volumes are fictional. CS2. The subject catalog is 23 units: 20 people across five teams and three workload identities. CS3. Data access and key-use permission are two separate axes; under the customer key option, decryption is the intersection of these two sets. There is one key per environment. CS4. Whether each subject’s job needs plaintext to do its work is recorded: the backup job and the platform team carry encrypted bytes without opening them. CS5. Encryption is in envelope form: every dataset has a data key, and the data key is wrapped with the master key. The wrapping and unwrapping really run. CS6. Under the customer key, every decrypt produces one audit-log line; under the provider key, key use never lands in the tenant’s log. The key rotation period is 365 days (fictional policy).
Three Options, One Question: Who Can Decrypt
// store.mjs — the datasets at rest, subjects, and three encryption options. MODEL. // The regional measurement network is FICTIONAL: team names, dataset names, and read counts are made up. export const ENV = ["development", "staging", "production"]; const BASE = ["raw-measurement", "summary", "invoice"]; const EXTRA = { development: [], staging: ["backup"], production: ["archive", "backup", "snapshot", "log-archive"] }; const GB = { "raw-measurement": 420, summary: 18, invoice: 6, archive: 900, backup: 1340, snapshot: 760, "log-archive": 210 }; const READS = { "raw-measurement": 4, summary: 9, invoice: 6, archive: 1, backup: 0, snapshot: 0, "log-archive": 2 }; // daily plaintext reads per subject export const DATASET = ENV.flatMap((e) => [...BASE, ...EXTRA[e]].map((t) => ({ name: `${e}/${t}`, env: e, type: t, gb: e === "production" ? GB[t] : Math.round(GB[t] / (e === "staging" ? 6 : 20)) }))); // needsPlaintext: does the subject's job require decrypted data. The backup job copies encrypted bytes. export const SUBJECT = [ { name: "measurement team", n: 6, needsPlaintext: true, keyEnv: ["development", "staging"], access: [["*", "raw-measurement"], ["*", "summary"]] }, { name: "billing team", n: 4, needsPlaintext: true, keyEnv: ["development", "staging"], access: [["*", "invoice"]] }, { name: "data team", n: 5, needsPlaintext: true, keyEnv: ["production"], access: [["production", "*"]] }, { name: "platform team", n: 3, needsPlaintext: false, keyEnv: [], access: [["*", "*"]] }, { name: "production on-call", n: 2, needsPlaintext: true, keyEnv: ["production"], access: [["production", "summary"], ["production", "invoice"]] }, { name: "collector identity", n: 1, needsPlaintext: true, keyEnv: ENV, access: [["*", "raw-measurement"]] }, { name: "reporting identity", n: 1, needsPlaintext: true, keyEnv: ["production"], access: [["*", "summary"], ["*", "invoice"]] }, { name: "backup job", n: 1, needsPlaintext: false, keyEnv: [], access: [["*", "backup"], ["*", "archive"], ["*", "snapshot"]] }, ]; export const AGE = { development: 288, staging: 141, production: 322 }; // customer key age export const ROTATION_PERIOD = 365; // fictional rotation period, days const matches = (s, [e, t]) => (e === "*" || s.env === e) && (t === "*" || s.type === t); export const canAccess = (z, k) => z.access.some((c) => matches(k, c)); export const hasKeyAccess = (z, k) => z.keyEnv.includes(k.env); export const SUBJECT_COUNT = SUBJECT.reduce((a, z) => a + z.n, 0); export const reads = (z, k) => (z.needsPlaintext ? z.n * READS[k.type] : 0); // Three options: what condition decides decryption. "complete" = with the missing key grants filled in. export const OPTION = { "no encryption": (z, k) => canAccess(z, k), "provider key": (z, k) => canAccess(z, k), "customer key": (z, k) => canAccess(z, k) && hasKeyAccess(z, k), "customer key (complete)": (z, k) => canAccess(z, k) && (hasKeyAccess(z, k) || z.needsPlaintext), }; export const pairs = (f) => SUBJECT.flatMap((z) => DATASET.filter((k) => f(z, k)).map((k) => ({ z, k }))); export const decrypters = (f) => SUBJECT.filter((z) => DATASET.some((k) => f(z, k))) .reduce((a, z) => a + z.n, 0); if (import.meta.url === `file://${process.argv[1]}`) { const totalGb = DATASET.reduce((a, k) => a + k.gb, 0); console.log(`model: ${DATASET.length} datasets (${totalGb} GB, fictional), ` + `${SUBJECT_COUNT} subjects / ${SUBJECT.length} groups, ${SUBJECT_COUNT * DATASET.length} possible pairs`); const B = [26, 15, 15, 10, 14, 17]; const row = (h) => h.map((v, j) => j ? String(v).padStart(B[j]) : String(v).padEnd(B[0])).join(""); console.log("\n" + row(["option", "can decrypt", "decrypt pairs", "widest", "logs/day", "infra path"])); for (const [name, f] of Object.entries(OPTION)) { const c = pairs(f); const logs = name === "no encryption" || name === "provider key" ? 0 : c.reduce((a, x) => a + reads(x.z, x.k), 0); console.log(row([name, decrypters(f), c.length, Math.max(...SUBJECT.map((z) => DATASET.filter((k) => f(z, k)).length)), logs, name === "no encryption" ? "open, uncounted" : "closed"])); } const open = pairs(OPTION["no encryption"]); const customer = new Set(pairs(OPTION["customer key"]).map((x) => `${x.z.name}|${x.k.name}`)); const closed = open.filter((x) => !customer.has(`${x.z.name}|${x.k.name}`)); console.log(`\npairs closed by moving to customer key: ${closed.length}; ` + `${closed.filter((x) => x.z.needsPlaintext).length} of these are legitimate work needing plaintext`); for (const z of SUBJECT) { const d = closed.filter((x) => x.z.name === z.name); if (d.length) console.log(` ${z.name.padEnd(21)} ${String(d.length).padStart(3)} pairs, ` + `${String(d.reduce((a, x) => a + reads(x.z, x.k), 0)).padStart(3)} daily reads, ` + `needs plaintext: ${z.needsPlaintext}`); } const every = pairs(OPTION["customer key"]).length; console.log(`\npairs no option ever closes: ${every} ` + `(the authorized identity already reads it); once grants are completed ${pairs(OPTION["customer key (complete)"]).length}`); const full = pairs(OPTION["customer key (complete)"]); console.log("\n" + row(["key (env)", "age (days)", "days left", "datasets", "decrypts/day"])); for (const e of ENV) { const k = full.filter((x) => x.k.env === e); console.log(row([e, AGE[e], ROTATION_PERIOD - AGE[e], DATASET.filter((x) => x.env === e).length, k.reduce((a, x) => a + reads(x.z, x.k), 0)])); } }
model: 14 datasets (3973 GB, fictional), 23 subjects / 8 groups, 322 possible pairs option can decrypt decrypt pairs widest logs/day infra path no encryption 23 45 14 0 open, uncounted provider key 23 45 14 0 closed customer key 19 20 7 371 closed customer key (complete) 19 27 7 503 closed pairs closed by moving to customer key: 25; 7 of these are legitimate work needing plaintext measurement team 2 pairs, 78 daily reads, needs plaintext: true billing team 1 pairs, 24 daily reads, needs plaintext: true platform team 14 pairs, 0 daily reads, needs plaintext: false reporting identity 4 pairs, 30 daily reads, needs plaintext: true backup job 4 pairs, 0 daily reads, needs plaintext: false pairs no option ever closes: 20 (the authorized identity already reads it); once grants are completed 27 key (env) age (days) days left datasets decrypts/day development 288 77 3 121 staging 141 224 4 121 production 322 43 7 261
The table’s first two rows are identical, and the lesson’s first finding sits there. Switching to the provider key does not reduce the number of subjects who can decrypt by even one: 23 subjects, 45 pairs, the widest subject holding all 14 datasets. Under the provider key, decryption happens automatically alongside data access; for the subject making the read request, encryption is an invisible layer. The one column it does change is the infrastructure path: disk, backup media, decommissioned hardware, and the provider’s own operational access all close. That column’s “open, uncounted” value is a line item in its own right; how many subjects can read through that path never appears in the tenant’s ledger.
The customer key row moves the measure for the first time: 23 subjects drop to 19, 45 pairs to 20, the widest grant from 14 datasets to 7. The cost of that sits in the breakdown below. Of the 25 pairs that close, 7 are legitimate work that genuinely needs plaintext: the measurement team’s production raw measurement and summary, the billing team’s production invoice, the reporting identity’s development and staging datasets — 132 reads a day stop. Once the missing key grants are added, this work reopens and the pair count rises from 20 to 27. Measured against 45, the remaining gap is 18 pairs, and all of them are subjects that do not need plaintext: the platform team’s 14 pairs and the backup job’s 4. The customer key’s real gain is those 18 pairs — from 45 to 27, forty percent. The rest ends up turning key permission into a copy of access permission.
The last column delivers the second gain: the same 503 decrypts produce 0 log lines under the provider key and 503 under the customer key. Key-use visibility is a line item independent of the narrowing in decrypt authority. The limit table counts key age: the production key is 322 days old, 43 days remain in the 365-day period, and that rotation concerns 7 datasets.
The Key’s Own Overhead: Rotation, Backup, and Loss
// envelope.mjs — envelope encryption really runs: the data key is wrapped, rotated, lost. // KEY AND CONTENT VALUES ARE FICTIONAL. The derivation seed is visible so the output can be reproduced. import { scryptSync, createCipheriv, createDecipheriv, createHash } from "node:crypto"; import { DATASET, pairs, reads, OPTION } from "./store.mjs"; const masterKey = (s) => scryptSync(`fictional-master-passphrase-s${s}`, "fictional-salt-0001", 32, { N: 16384 }); const dataKey = (k) => createHash("sha256").update(`fictional-data-key|${k.name}`).digest(); const iv = (label) => createHash("sha256").update(`iv|${label}`).digest().subarray(0, 12); const content = (k) => Buffer.from(`fictional-record|${k.name}|`.repeat(Math.max(4, k.gb))); const wrap = (k, s) => { // wraps the data key with the master key const c = createCipheriv("aes-256-gcm", masterKey(s), iv(`${k.name}#${s}`)); const g = Buffer.concat([c.update(dataKey(k)), c.final()]); return { dataset: k.name, version: s, body: g, tag: c.getAuthTag() }; }; const unwrap = (wrapped, s) => { // unwraps a wrapped key; wrong key throws const d = createDecipheriv("aes-256-gcm", masterKey(s), iv(`${wrapped.dataset}#${wrapped.version}`)); d.setAuthTag(wrapped.tag); return Buffer.concat([d.update(wrapped.body), d.final()]); }; const encrypt = (k, dk) => { const c = createCipheriv("aes-256-gcm", dk, iv(k.name)); return { body: Buffer.concat([c.update(content(k)), c.final()]), tag: c.getAuthTag() }; }; const decrypt = (k, dk, box) => { const d = createDecipheriv("aes-256-gcm", dk, iv(k.name)); d.setAuthTag(box.tag); return Buffer.concat([d.update(box.body), d.final()]); }; const wrapped = DATASET.map((k) => wrap(k, 1)); const box = DATASET.map((k, i) => encrypt(k, unwrap(wrapped[i], 1))); const objectBytes = box.reduce((a, x) => a + x.body.length, 0); const wrappedBytes = wrapped.reduce((a, x) => a + x.body.length + x.tag.length, 0); const correct = DATASET.every((k, i) => decrypt(k, unwrap(wrapped[i], 1), box[i]).equals(content(k))); console.log(`${DATASET.length} datasets encrypted: ${objectBytes} bytes object, ${wrappedBytes} bytes ` + `wrapped key; all decrypted back correctly: ${correct}`); console.log(`sample wrapped key ${wrapped[0].dataset} -> ${wrapped[0].body.toString("base64")}`); const B = [36, 8, 9, 16]; const row = (h) => h.map((v, j) => j ? String(v).padStart(B[j]) : String(v).padEnd(B[0])).join(""); console.log("\n" + row(["rotation type", "wraps", "objects", "bytes touched"])); const newWrapped = DATASET.map((k) => wrap(k, 2)); // master key 1 -> 2 const opened = DATASET.map((k, i) => decrypt(k, unwrap(newWrapped[i], 2), box[i]).equals(content(k))); console.log(row(["master key (wrap only)", DATASET.length, 0, wrappedBytes])); console.log(row(["data key (re-encrypt)", DATASET.length, DATASET.length, objectBytes + wrappedBytes])); console.log(`objects still open without corruption after the master key rotates: ` + `${opened.every(Boolean)} (${opened.filter(Boolean).length}/${DATASET.length})`); // key loss: wrapped keys remain, master key version 2 is gone. Tried with a fake version. let lostDatasets = 0, lostGb = 0; for (const [i, s] of newWrapped.entries()) { try { decrypt(DATASET[i], unwrap(s, 9), box[i]); } catch { lostDatasets++; lostGb += DATASET[i].gb; } } console.log(`\nmaster key loss: wrapped keys will not open, ${lostDatasets}/${DATASET.length} datasets ` + `unreadable, ${lostGb} GB permanently lost`); const restored = newWrapped.map((s, i) => decrypt(DATASET[i], unwrap(s, 2), box[i]).equals(content(DATASET[i]))); console.log(`restored from the key backup: ${restored.filter(Boolean).length}/` + `${DATASET.length} datasets decrypt correctly — an encrypted-byte backup alone is not enough`); const dailyReads = pairs(OPTION["customer key (complete)"]).reduce((a, x) => a + reads(x.z, x.k), 0); console.log(`\naudit log: ${dailyReads} key uses/day under customer key, ` + `${dailyReads * 30} lines in 30 days; under provider key the same ${dailyReads} decrypts produce 0 lines`);
14 datasets encrypted: 146675 bytes object, 672 bytes wrapped key; all decrypted back correctly: true sample wrapped key development/raw-measurement -> MpZT6WZqv/IAudsxlXHD004z+NRBc5+ITK0DIDlIBdI= rotation type wraps objects bytes touched master key (wrap only) 14 0 672 data key (re-encrypt) 14 14 147347 objects still open without corruption after the master key rotates: true (14/14) master key loss: wrapped keys will not open, 14/14 datasets unreadable, 3973 GB permanently lost restored from the key backup: 14/14 datasets decrypt correctly — an encrypted-byte backup alone is not enough audit log: 503 key uses/day under customer key, 15090 lines in 30 days; under provider key the same 503 decrypts produce 0 lines
The encryption really ran: 14 datasets were encrypted, the wrapped keys were unwrapped, and the content came back byte for byte. Rotating the master key touches 14 wrap operations and 672 bytes; it never touches object bytes. Rotating the data key re-encrypts all 14 objects in full on top of the same 14 wraps: 147,347 bytes, roughly 219 times as many. This is the wrapping layer’s reason for existing — a frequently rotated key touches the key, not the data.
In exchange, this follows: after the master key rotates, the data key is still the same data key. Its wrapped form changed; what it wraps did not. If the data key was left exposed somewhere, rotation does not close that exposure; closing it requires moving to the 147,347-byte row. Cheap rotation and closing rotation are not the same operation, and they sit on separate rows in the table.
The loss row is the customer key’s harshest line item, and it is a trial that actually ran: with master key version 2 unavailable, none of the 14 datasets open, and 3,973 GB becomes permanently unreadable. The encrypted-byte backup was sitting there and did no good; the same 14 datasets decrypted correctly once version 2 came back from the key backup. Moving to the customer key means adding the key to the backup plan’s scope, not the data.
The Path Encryption Does Not Close
The 20 pairs that stay open under all three options — 27 once grants are completed — are the line item this lesson needs to name outright. These are reads by authorized identities. The data team’s 5 members open all seven production datasets, because both data access and key permission are concentrated in them. Encryption at rest does nothing against this path, and it is not meant to: the subject has passed authentication, the policy document has granted permission, the key service has decrypted.
The path encryption does close is a single column: the infrastructure side the tenant cannot read, count, or audit. The provider key closes that column and asks nothing in return. The customer key adds two more things — it separates decrypt authority from access authority (45 pairs down to 27) and drops key use into the tenant’s own log (from 0 lines to 503 a day) — and the price it asks is also counted: 7 legitimate jobs stopping, a new secret that needs backing up, and a key that, once lost, makes 3,973 GB unrecoverable.
Summary
- The provider key does not lower the number of subjects who can decrypt from 23, and leaves pairs at 45; the only thing it closes is the infrastructure path the tenant cannot count.
- The customer key drops pairs from 45 to 20; once missing grants are completed it rises to 27, and the lasting gain is the 18 pairs of subjects that do not need plaintext.
- The cost of narrowing: 7 of the 25 pairs that close are legitimate work needing plaintext, and 132 reads a day stop; the widest subject’s grant drops from 14 datasets to 7.
- Master key rotation is 14 wraps and 672 bytes, data key rotation is 14 objects and 147,347 bytes; because cheap rotation does not change the data key, it does not close a data key that has been left exposed.
- Key loss was demonstrated by actually running it: 14/14 datasets fail to open, 3,973 GB is permanently lost; the encrypted-byte backup is not enough, the key backup restores 14/14 datasets.
- Key use shows up only under the customer key: the same 503 decrypts produce 0 lines versus 503 a day, 15,090 lines in 30 days; 43 days remain in the production key’s 365-day period.
Next Step
The question for data at rest is answered. But data does not only sit at rest — it keeps moving between datasets, environments, and meter endpoints and the collector. The next lesson measures that movement: how many connection legs are encrypted, how many are left open on an “internal network” rationale, how many connections break when encryption is enforced by policy, and how many configuration items change.
To keep your progress and take notes, Log in
My notes
Log in to take notes.