---
title: 'Encryption at Rest'
source: 'https://academia.sh/en/courses/cloud-governance/encryption-at-rest'
course: 'Cloud Governance, Security and Cost'
language: en
updated: '2026-09-13T05:07:35+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

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

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

```js
// 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

```js
// 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.
