Block Ciphers: Rounds and Modes
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
spn-cipher.ts
import type { Tracer } from "../trace";/** A block is sixteen bits: four nibbles through four S-boxes, a 4 by 4 transpose between rounds. */export const BLOCK_BITS = 16;export const KEY_BITS = 32;export const DEFAULT_ROUNDS = 4;export const MAX_ROUNDS = 4;/** Sixteen blocks are the stage's 16 by 16 bit image. */export const MAX_BLOCKS = 16;/** Heys' S-box, the one every tutorial on linear and differential cryptanalysis is written against. */export const SBOX: readonly number[] = [0xe, 0x4, 0xd, 0x1, 0x2, 0xf, 0xb, 0x8, 0x3, 0xa, 0x6, 0xc, 0x5, 0x9, 0x0, 0x7];export const INVERSE_SBOX: readonly number[] = SBOX.map((_, output) => SBOX.indexOf(output));export type CipherMode = "ecb" | "cbc";export interface SpnRun { plain: number[]; cipher: number[]; roundKeys: number[]; mode: CipherMode; iv: number; initialVector: number; decrypted: number[]; roundTrip: boolean; /** Cipher blocks equal to an earlier cipher block: the pattern a mode leaks. */ identical: number;}const MASK16 = 0xffff;const rotl16 = (word: number, n: number): number => ((word << n) | (word >>> (16 - n))) & MASK16;/** * A substitution-permutation network, the shape of AES at toy size: * each round xors a round key into the 16-bit block, pushes each * nibble through the S-box, and transposes the bits as a 4 by 4 grid, * so that after a few rounds every output bit depends on every input * bit and every key bit (confusion from the S-box, diffusion from the * permutation). A block cipher is a keyed permutation of blocks, not * a cipher for messages: a mode decides how blocks relate. Electronic * codebook encrypts each block alone and so maps equal blocks to * equal blocks, which leaks the shape of the plaintext; cipher block * chaining xors each block with the previous ciphertext first, so * equal blocks differ and the pattern is gone. Four rounds and a * 32-bit key are far too few to be secure; they are enough to see * the mechanism. * * Time: O(rounds) per block. Space: O(1) beyond the blocks. */export class SpnCipher { constructor( private readonly tracer?: Tracer, readonly rounds: number = DEFAULT_ROUNDS, ) { if (!Number.isInteger(rounds) || rounds < 1 || rounds > MAX_ROUNDS) throw new RangeError(`rounds must be a whole number from 1 to ${MAX_ROUNDS}, got ${rounds}`); } /** The bit permutation: bit 4i + j of the input becomes bit 4j + i, the transpose of a 4 by 4 grid, its own inverse. */ static pbox(state: number): number { let out = 0; for (let i = 0; i < BLOCK_BITS; i++) { if (state & (1 << i)) out |= 1 << (4 * (i % 4) + Math.floor(i / 4)); } return out; } /** The round keys: one per round plus a final whitening key, each the key's halves folded, rotated, and offset by the round. */ schedule(key: number): number[] { if (!Number.isInteger(key) || key < 0 || key > 0xffffffff) throw new RangeError(`the key is ${KEY_BITS} bits, got ${key}`); const folded = ((key & MASK16) ^ (key >>> 16)) & MASK16; const keys: number[] = []; for (let round = 0; round <= this.rounds; round++) { const roundKey = rotl16(folded, (3 * round) % 16) ^ ((0x9e37 * (round + 1) + ((key >>> ((round * 8) % 32)) & 0xff)) & MASK16); keys.push(roundKey); // ▸ one round key per round and a final one, all from the 32-bit key this.tracer?.emit("schedule", { round, key: roundKey, keyHex: roundKey.toString(16).padStart(4, "0") }); } return keys; } private mixKey(state: number, key: number, round: number, block: number): number { const after = (state ^ key) & MASK16; // ▸ xor the round key in: the only place the key touches the block this.tracer?.emit("mix", { block, round, before: state, key, after }); return after; } private substitute(state: number, box: readonly number[], round: number, block: number): number { private substitute(state: number, substitutionBox: readonly number[], round: number, block: number): number { let after = 0; const nibbles: number[] = []; for (let i = 0; i < 4; i++) { const nibble = box[(state >>> (4 * i)) & 0xf]!; const nibble = substitutionBox[(state >>> (4 * i)) & 0xf]!; nibbles.unshift(nibble); after |= nibble << (4 * i); } // ▸ push each nibble through the s-box: confusion this.tracer?.emit("sub", { block, round, before: state, after, nibbles }); return after; } private permute(state: number, round: number, block: number): number { const after = SpnCipher.pbox(state); // ▸ transpose the bits as a 4 by 4 grid: diffusion this.tracer?.emit("perm", { block, round, before: state, after }); return after; } /** One block through the rounds: key, S-box, permutation, with no permutation in the last round and a final key. */ encryptBlock(block: number, roundKeys: readonly number[], index: number): number { let state = block & MASK16; for (let round = 0; round < this.rounds; round++) { state = this.mixKey(state, roundKeys[round]!, round, index); state = this.substitute(state, SBOX, round, index); if (round < this.rounds - 1) state = this.permute(state, round, index); } return this.mixKey(state, roundKeys[this.rounds]!, this.rounds, index); } /** The rounds undone in reverse order with the inverse S-box; the permutation is its own inverse. */ decryptBlock(cipher: number, roundKeys: readonly number[], index: number): number { let state = this.mixKey(cipher & MASK16, roundKeys[this.rounds]!, this.rounds, index); for (let round = this.rounds - 1; round >= 0; round--) { if (round < this.rounds - 1) state = this.permute(state, round, index); state = this.substitute(state, INVERSE_SBOX, round, index); state = this.mixKey(state, roundKeys[round]!, round, index); } return state; } /** Every block under the mode: alone in ECB, xored with the previous cipher block in CBC, the first with the IV. */ encrypt(blocks: readonly number[], roundKeys: readonly number[], mode: CipherMode, iv: number): number[] { encrypt(blocks: readonly number[], roundKeys: readonly number[], mode: CipherMode, initialVector: number): number[] { if (blocks.length > MAX_BLOCKS) throw new RangeError(`at most ${MAX_BLOCKS} blocks, got ${blocks.length}`); const cipher: number[] = []; let previous = iv & MASK16; let previous = initialVector & MASK16; blocks.forEach((plain, block) => { let input = plain & MASK16; if (mode === "cbc") { input = (input ^ previous) & MASK16; // ▸ in cbc, xor the previous cipher block into this one first this.tracer?.emit("chain", { block, previous, before: plain & MASK16, after: input }); } const out = this.encryptBlock(input, roundKeys, block); cipher.push(out); previous = out; // ▸ the block is encrypted; equal inputs give equal outputs this.tracer?.emit("output", { block, plain: plain & MASK16, cipher: out, mode }); }); return cipher; } /** The inverse of encrypt under the same mode and IV. */ decrypt(cipher: readonly number[], roundKeys: readonly number[], mode: CipherMode, iv: number): number[] { decrypt(cipher: readonly number[], roundKeys: readonly number[], mode: CipherMode, initialVector: number): number[] { const plain: number[] = []; let previous = iv & MASK16; let previous = initialVector & MASK16; cipher.forEach((block, index) => { let out = this.decryptBlock(block, roundKeys, index); if (mode === "cbc") out = (out ^ previous) & MASK16; plain.push(out); previous = block & MASK16; }); return plain; } /** Schedule the key, encrypt the blocks under the mode, decrypt them untraced to check the round trip, and count repeated cipher blocks. */ run(blocks: readonly number[], key: number, mode: CipherMode, iv: number): SpnRun { run(blocks: readonly number[], key: number, mode: CipherMode, initialVector: number): SpnRun { const roundKeys = this.schedule(key); const cipher = this.encrypt(blocks, roundKeys, mode, iv); const cipher = this.encrypt(blocks, roundKeys, mode, initialVector); const decrypted = new SpnCipher(undefined, this.rounds).decrypt(cipher, roundKeys, mode, iv); const decrypted = new SpnCipher(undefined, this.rounds).decrypt(cipher, roundKeys, mode, initialVector); const plain = blocks.map((b) => b & MASK16); const roundTrip = decrypted.length === plain.length && decrypted.every((b, i) => b === plain[i]); const identical = cipher.filter((c, i) => cipher.indexOf(c) < i).length; return { plain, cipher, roundKeys, mode, iv: iv & MASK16, decrypted, roundTrip, identical }; return { plain, cipher, roundKeys, mode, initialVector: initialVector & MASK16, decrypted, roundTrip, identical }; }}Codebook mode: the plaintext had 15 repeated blocks, the ciphertext has 15. Decrypting gives the plaintext back.