Lesson 13 / 17
Cryptographic Misuse
The coverage of cryptography call points and the concrete consequence of misuse: how seven of fourteen call points stay uncovered by the central layer, how reusing the same key and the same starting value makes forty positions readable, how the format check in encryption without an integrity tag misses four of twenty-four changes, and how a new call point is covered automatically once the primitive call is confined to a single file.
Published Updated
In this lesson
- Counts the cryptography call points as an inventory and separates the points the central layer covers from the uncovered points
- Measures, in a hand-written model, the information leaked by reusing the same key and the same starting value: how many positions have a directly readable difference
- Counts how many changes the format check misses in encryption without an integrity tag
Contents
The previous lesson audited who could do what in code: it counted the endpoints in the authorization matrix and measured the case where a concurrent request passed the same check twice. All of those checks rested on one thing. A session carried a signature, a key had generated the field application’s upload token, the local store had been locked with an encryption call. This topic takes up those items, and the first question is not where they are stored but how they are called.
The Identity, Access and Cryptography course measured the properties of algorithms. Here those properties are the input; what is measured is the call point. How many points in a codebase call cryptography, how many of them pass through a central layer, and what the wrong setting at the uncovered points concretely costs.
Cryptography Call Points
SC1 — the fictional regional measurement network’s codebase has fourteen cryptography call points. Some of them call the four functions of a central crypto layer; the rest make the primitive call directly and choose settings such as mode, starting value, and salt on their own. The inventory below is a model; the source names are fictional.
// crypto/coverage.mjs — inventory of the cryptography call points in the fictional // measurement network's codebase (model). source: the component making the call; layer: // whether it goes through the central crypto layer; flaw: the wrong setting chosen in a // direct call. const points = [ ['portal/session', 'encryption', false, 'no-tag mode'], ['portal/registration', 'password hash', true, null], ['portal/invoice-link', 'signature', true, null], ['ingestion/unpacking', 'encryption', true, null], ['ingestion/backup-path', 'encryption', false, 'fixed starting value'], ['ingestion/sync', 'hash', false, 'custom hash chain'], ['field/upload-token', 'signature', false, null], ['field/local-cache', 'encryption', false, null], ['report/export', 'encryption', true, null], ['report/filename', 'hash', true, null], ['report/archive', 'encryption', false, 'no-tag mode'], ['work-order/signature', 'signature', true, null], ['work-order/legacy-endpoint', 'password hash', false, 'unsalted hash'], ['notification/callback', 'signature', true, null], ]; const countWhere = (set, predicate) => set.filter(predicate).length; const flawTally = (set) => { const g = {}; for (const [, , , k] of set.filter(([, , layer]) => !layer)) if (k) g[k] = (g[k] ?? 0) + 1; return Object.entries(g).sort((a, b) => b[1] - a[1]); }; const write = (g, ...s) => console.log(s.map((v, i) => (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join('')); const D = [-26, 10, 10, 12, 12]; write(D, 'operation type', 'points', 'in layer', 'uncovered', 'flawed'); for (const type of ['encryption', 'signature', 'hash', 'password hash']) { const t = points.filter(([, i]) => i === type); write(D, type, t.length, countWhere(t, ([, , k]) => k), countWhere(t, ([, , k]) => !k), countWhere(t, ([, , , k]) => k)); } write(D, 'total', points.length, countWhere(points, ([, , k]) => k), countWhere(points, ([, , k]) => !k), countWhere(points, ([, , , k]) => k)); console.log('\ndefect distribution of the uncovered points:'); for (const [name, n] of flawTally(points)) console.log(` ${name.padEnd(24)}${n}`); // What happens when a new point is added: three new call points, two separate arrangements. const added = [ ['field/offline-queue', 'encryption'], ['portal/export', 'signature'], ['ingestion/measurement-archive', 'encryption'], ]; console.log('\nthree new call points are being added:'); const E = [-40, 8, 11, 19]; write(E, 'arrangement', 'points', 'in layer', 'new gap candidates'); // 1) Primitive call free: a new point is not obliged to use the layer. write(E, 'layer exists, direct calls allowed', points.length + added.length, countWhere(points, ([, , k]) => k), added.length); // 2) Primitive call gathered into one file: a point stepping outside the layer does not compile. write(E, 'primitive calls confined to one file', points.length + added.length, countWhere(points, ([, , k]) => k) + added.length, 0); console.log(`\nin the second arrangement, points that must be closed first: ` + `${countWhere(points, ([, , k]) => !k)}`);
operation type points in layer uncovered flawed encryption 6 2 4 3 signature 4 3 1 0 hash 2 1 1 1 password hash 2 1 1 1 total 14 7 7 5 defect distribution of the uncovered points: no-tag mode 2 fixed starting value 1 custom hash chain 1 unsalted hash 1 three new call points are being added: arrangement points in layer new gap candidates layer exists, direct calls allowed 17 7 3 primitive calls confined to one file 17 10 0 in the second arrangement, points that must be closed first: 7
The total row gives this lesson’s three columns: there are fourteen points, seven of them are covered by the central layer, and seven are left uncovered. The fourth column says that being left uncovered does not immediately mean flawed — five of the seven points are flawed, two call it with the correct setting. Those two points are correct today because the person who wrote them chose the setting knowingly; no structure guarantees they will still be correct tomorrow. The measure of coverage is not the number of points that are currently flawed, but the number of points that could become flawed, and that number is seven.
The defect distribution shows four distinct classes, and two of them come from the same root: choosing a mode without an integrity tag and leaving the starting value fixed. The third is writing a custom hash chain, the fourth is hashing a password without a salt. The two measurements below count the concrete counterpart first of the third class, then of the first two.
The Measure of a Custom Mix
The ingestion service uses a hand-written mixing function to distinguish repeated meter readings: the code of each character is summed and a thirty-two-bit value is produced. The function works, it passes the tests, and it never throws an error.
// crypto/chain.mjs — measuring the "custom hash chain" flaw from the inventory: the // ingestion service uses a hand-written mixing function to distinguish repeated readings // (model). import { createHash } from 'node:crypto'; const RECORDS = 20000; const keys = Array.from({ length: RECORDS }, (_, i) => `MTR-${4000 + (i % 2500)}|2026-03-${String(1 + (i % 28)).padStart(2, '0')}T06:00Z|${41000 + i}`); // Hand-written mix: sum of character codes, 32-bit output. const customMix = (s) => { let h = 0; for (let i = 0; i < s.length; i++) h = (h + s.charCodeAt(i)) | 0; return h >>> 0; }; const builtinHash = (s) => createHash('sha256').update(s).digest().readUInt32BE(0); const measure = (f) => { const seen = new Set(); let dropped = 0; for (const a of keys) { const k = f(a); if (seen.has(k)) dropped += 1; else seen.add(k); } return [seen.size, dropped]; }; const expected = RECORDS - 2 ** 32 * (1 - (1 - 1 / 2 ** 32) ** RECORDS); console.log(`${RECORDS} fictional reading keys, all distinct, output width 32 bits:`); console.log(` theoretical expected silent drop at 32 bits: ${expected.toFixed(2)}`); for (const [name, f] of [['builtin hash', builtinHash], ['hand-written mix', customMix]]) { const [distinct, dropped] = measure(f); console.log(` ${name.padEnd(22)} distinct value ${String(distinct).padStart(5)}, ` + `silent drops ${dropped}`); }
20000 fictional reading keys, all distinct, output width 32 bits: theoretical expected silent drop at 32 bits: 0.05 builtin hash distinct value 20000, silent drops 0 hand-written mix distinct value 67, silent drops 19933
At a thirty-two-bit output width, a collision among twenty thousand distinct keys is theoretically expected at 0.05; the first thirty-two bits of the builtin hash hit this expectation and produce twenty thousand distinct values. The hand-written mix produces only sixty-seven distinct values in the same set, and nineteen thousand nine hundred thirty-three readings are silently dropped as “already seen.” The reason is plain: the summation ignores both the order of the characters and the length of the record, so the thirty-two-bit width of the output space is never actually used. This is the concrete consequence of writing your own algorithm — what you write runs, no one sees an error, and there is no property that was never considered.
Same Key, Same Starting Value
SC2 — in an encryption that works in stream mode, if the same key and the same starting value are used twice, the byte difference between the two ciphertexts equals the byte difference between the two plaintexts. This is not an algorithm flaw; it is a violation of the construction’s condition of use. The measurement is done on our own hand-written model: the stream generator below is not a real cipher, and it stands in for an approach that writes its own cipher in production code.
// crypto/model.mjs — comparing our own simple stream model with built-in authenticated // encryption at the same scale. The model is not a real cipher; it exists only to count // the information that leaks when the starting value is reused and when there is no // integrity tag. Two meter readings from the fictional regional measurement network are used. import { createHash, createCipheriv, createDecipheriv } from 'node:crypto'; const KEY = createHash('sha256').update('fictional-field-key').digest(); const READING_A = Buffer.from('SAY-4417|2026-03-01T06:00Z|41982|sube-01'); const READING_B = Buffer.from('SAY-4418|2026-03-01T06:00Z|41982|sube-02'); const FORMAT = /^SAY-\d{4}\|\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\|\d+\|sube-\d{2}$/; // Our own stream generator: derives a byte sequence from the key and the starting value. function stream(sv, length) { const chunks = []; for (let block = 0; chunks.length * 32 < length; block++) { chunks.push(createHash('sha256').update(KEY).update(sv).update(String(block)).digest()); } return Buffer.concat(chunks).subarray(0, length); } const modelEncrypt = (plain, sv) => Buffer.from(plain.map((b, i) => b ^ stream(sv, plain.length)[i])); const modelDecrypt = (cipher, sv) => Buffer.from(cipher.map((b, i) => b ^ stream(sv, cipher.length)[i])); // Built-in construction: authenticated encryption (AES-256-GCM); the tag travels with the packet. const aeEncrypt = (plain, sv) => { const s = createCipheriv('aes-256-gcm', KEY, sv); return { body: Buffer.concat([s.update(plain), s.final()]), tag: s.getAuthTag() }; }; const aeDecrypt = ({ body, tag }, sv) => { const c = createDecipheriv('aes-256-gcm', KEY, sv); c.setAuthTag(tag); return Buffer.concat([c.update(body), c.final()]); }; const SV1 = Buffer.alloc(12, 7), SV2 = Buffer.alloc(12, 9); const equalCount = (x, y) => [...x].filter((b, i) => b === y[i]).length; const write = (g, ...s) => console.log(s.map((v, i) => (g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i]))).join('')); // 1) Same key, same starting value: comparing two ciphertexts. console.log(`SC2 — same key, two readings, length ${READING_A.length} bytes,` + ` equal positions in plaintext ${equalCount(READING_A, READING_B)}:`); const D = [-32, 14, 14, 14]; write(D, 'arrangement', 'equal cipher', 'diff readable', 'leaked bytes'); for (const [name, c1, c2] of [ ['model, same starting value', modelEncrypt(READING_A, SV1), modelEncrypt(READING_B, SV1)], ['model, separate starting value', modelEncrypt(READING_A, SV1), modelEncrypt(READING_B, SV2)], ['tagged, separate starting value', aeEncrypt(READING_A, SV1).body, aeEncrypt(READING_B, SV2).body], ]) { const readable = [...c1].every((b, i) => (b ^ c2[i]) === (READING_A[i] ^ READING_B[i])); write(D, name, equalCount(c1, c2), readable ? 'yes' : 'no', readable ? READING_A.length : 0); } // 2) Encryption without an integrity tag: who catches the tampered packet. const TRIALS = 24; console.log(`\nSC3 — a single bit is flipped in each of ${TRIALS} packets:`); const E = [-32, 14, 14, 14]; write(E, 'arrangement', 'format passed', 'caught', 'changed bytes'); let passed = 0, changed = 0, aePassed = 0; for (let k = 0; k < TRIALS; k++) { const sv = Buffer.alloc(12, k + 16); const flipped = Buffer.from(modelEncrypt(READING_A, sv)); flipped[k] ^= 1 << (k % 8); const decoded = modelDecrypt(flipped, sv); changed += [...decoded].filter((b, i) => b !== READING_A[i]).length; if (FORMAT.test(decoded.toString('latin1'))) passed += 1; const packet = aeEncrypt(READING_A, sv); packet.body[k] ^= 1 << (k % 8); try { aeDecrypt(packet, sv); aePassed += 1; } catch { /* tag rejected it */ } } write(E, 'model + format check', passed, TRIALS - passed, changed); write(E, 'authenticated encryption', aePassed, TRIALS - aePassed, 0);
SC2 — same key, two readings, length 40 bytes, equal positions in plaintext 38: arrangement equal cipher diff readable leaked bytes model, same starting value 38 yes 40 model, separate starting value 0 no 0 tagged, separate starting value 0 no 0 SC3 — a single bit is flipped in each of 24 packets: arrangement format passed caught changed bytes model + format check 4 20 24 authenticated encryption 0 24 0
The first row of the first table gives the leak as a number. The two meter readings are forty-byte records and are equal to each other at thirty-eight positions in plaintext — both carry the same date, the same reading value, and the same format, and differ only in the meter number and the branch code. When encrypted with the same starting value, the ciphertexts also come out equal at exactly thirty-eight positions. Anyone looking at the ciphertext reads where the two records are the same and where they differ, without ever knowing the key.
The “diff readable” column says something sharper: across all forty positions, the byte difference between the two ciphertexts equals the byte difference between the two plaintexts. Encryption hides only the record’s raw form, not its value; the entire relationship between the two records passes through to the ciphertext unchanged. In a set of readings, this means it is possible to count which records are identical and when a branch’s readings stopped. The second and third rows give zero on the same measure: when the starting value is separate for each encryption, the equal-position count is zero and there is no readable difference. The flaw is not in the encryption; it is in leaving a single parameter fixed.
Encryption That Does Not Notice Tampering
SC3 — encryption without an integrity tag has no item that will refuse to decrypt; every ciphertext decodes into some plaintext. The second table gives this sentence’s coverage counterpart. A single bit was flipped in each of twenty-four packets; each flip changed exactly one byte in the decoded plaintext — twenty-four bytes total, one per packet. In stream mode, a change at one position of the ciphertext lands at the same position of the plaintext; it neither spreads nor gets noticed.
At this point, the only defense is the receiving side’s format check, and the table counts its coverage: it catches twenty of the twenty-four changes and misses four. The four it misses are the ones that land on positions that still match the plaintext’s format pattern — the digits of the meter number or of the reading value. The result is a packet that still looks valid but carries a different value; on the billing side, the counterpart of this is a silently wrong consumption figure. A defense can cover most of the points and still, exactly the points it does not cover can be the ones that matter.
The last row is the other end of the same measure: with authenticated encryption, twenty-four of the twenty-four changes were rejected and the format check had no work left to do. The difference here is not a difference in strength; it is a difference in coverage — the integrity check covers the whole packet, the format check covers only the fields that fall into the pattern.
When a New Point Is Added
The third table asks the course’s third question. Three new call points are added to the codebase: the field application’s offline queue, the portal’s export flow, the ingestion service’s measurement archive. In the first arrangement, the central layer exists, but the primitive call is free. The point count rises from fourteen to seventeen, the number of points in the layer stays at seven, and the three new points become direct exposure candidates. Because the layer binds no one, a new point is not obliged to use it; coverage does not stay fixed, it drops with every addition.
In the second arrangement, the primitive calls are gathered into a single file, and nowhere outside that file can call the encryption, signature, or hash primitive. The three new points must pass through the layer; the number of points in the layer rises to ten, and the new exposure candidate count is zero. What keeps coverage fixed is not that the layer exists, but that it is impossible to step outside the layer. The cost is written on the last line: before moving to this arrangement, all seven of the seven uncovered points must be migrated, because the single-file rule only works when it has no exception. A debt of seven points replaces a decision that would otherwise be repeated at each of the three new points.
Summary
- The fictional measurement network’s codebase has fourteen cryptography call points; seven pass through the central layer, seven are left uncovered, and five of the uncovered ones call it with a flawed setting. The measure of coverage is not the number of points that are flawed, but the number of points that could become flawed.
- The hand-written summation mix produced sixty-seven distinct values across twenty thousand distinct keys, and 19,933 readings dropped silently; the first thirty-two bits of the builtin hash matched the theoretical expectation and gave zero drops.
- When the same key and the same starting value are used, the ciphertexts of two forty-byte records come out equal at thirty-eight positions, and the difference is directly readable across all forty positions; with a separate starting value, these numbers are zero.
- In encryption without an integrity tag, flipping a single bit changes exactly one byte in the plaintext; the format check catches twenty of the twenty-four changes and misses four. Authenticated encryption rejects all twenty-four of the same twenty-four.
- While the primitive call is free, three new points add three exposure candidates and coverage drops; once the primitive call is confined to a single file, coverage stays fixed, and the cost is migrating seven points first.
Next Step
Every measurement in this lesson took one thing as given: that the source generating the key and the starting value is unpredictable. Yet that source is also a call point in the codebase, and it does not appear as a separate row in the fourteen-point inventory. The calls that generate the field application’s upload token, the portal’s password reset link, and the invoice filename share the same question: can a third party precompute the generated value? The next lesson answers this question by running two generators at the same scale — how many values come out precomputed once the seed is known, and how many of the token-generating points call the weak generator.
To keep your progress and take notes, Log in
My notes
Log in to take notes.