---
title: 'Cryptographic Misuse'
source: 'https://academia.sh/en/courses/secure-coding/cryptographic-misuse'
course: 'Secure Coding'
language: en
updated: '2026-09-26T03:02:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

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

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.

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

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

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