/** Bounded, reproducible teaching calculations; the interactive lab uses Web Crypto separately. */ export interface IntegerAnalysis { n: number; isPrime: boolean; divisor: number | null; testedDivisors: number[]; } export interface Sha256Constants { initialWords: string[]; roundConstants: string[]; } export interface Sha256Trace extends Sha256Constants { inputHex: string; paddedHex: string; schedule: string[]; /** FIPS indices 0–63; words are a, b, c, d, e, f, g, h after that round. */ rounds: { round: number; words: string[] }[]; /** Includes the final addition of the initial hash words (feed-forward). */ digest: string; } function requireIntegerInRange(value: number, minimum: number, maximum: number): void { if (!Number.isInteger(value) || value < minimum || value > maximum) { throw new RangeError(`Expected an integer from ${minimum} to ${maximum}`); } } /** Sieve of Eratosthenes, including the upper bound. */ export function primesUpTo(limit: number): number[] { requireIntegerInRange(limit, 0, 100_000); const composite = new Uint8Array(limit + 1); for (let candidate = 2; candidate * candidate <= limit; candidate++) { if (composite[candidate]) continue; for (let multiple = candidate * candidate; multiple <= limit; multiple += candidate) { composite[multiple] = 1; } } const primes: number[] = []; for (let candidate = 2; candidate <= limit; candidate++) { if (!composite[candidate]) primes.push(candidate); } return primes; } /** Try prime divisors no greater than sqrt(n), stopping at the first factor. */ export function analyzeInteger(n: number): IntegerAnalysis { requireIntegerInRange(n, 2, 1_000_000); const testedDivisors: number[] = []; for (const divisor of primesUpTo(Math.floor(Math.sqrt(n)))) { testedDivisors.push(divisor); if (n % divisor === 0) return { n, isPrime: false, divisor, testedDivisors }; } return { n, isPrime: true, divisor: null, testedDivisors }; } const BIG_ZERO = BigInt(0); const BIG_ONE = BigInt(1); const WORD_MASK = (BIG_ONE << BigInt(32)) - BIG_ONE; /** Exact floor of a positive integer's square or cube root, by binary search. */ function integerRoot(value: bigint, degree: 2 | 3): bigint { let lower = BIG_ZERO; let upper = BIG_ONE << BigInt(Math.ceil(value.toString(2).length / degree)); while (upper - lower > BIG_ONE) { const middle = (lower + upper) >> BIG_ONE; const power = degree === 2 ? middle * middle : middle * middle * middle; if (power <= value) lower = middle; else upper = middle; } return lower; } function fractionalRootWord(prime: number, degree: 2 | 3): number { // floor(root(p) × 2^32) = floor(root(p × 2^(32 × degree))). // Its low 32 bits are the first 32 fractional bits; no floating-point root is used. const scaled = BigInt(prime) << BigInt(32 * degree); return Number(integerRoot(scaled, degree) & WORD_MASK); } function hexWord(word: number): string { return (word >>> 0).toString(16).padStart(8, '0'); } let cachedConstants: { initial: readonly number[]; round: readonly number[] } | undefined; function constantWords(): { initial: readonly number[]; round: readonly number[] } { if (!cachedConstants) { const primes = primesUpTo(311); // 311 is the 64th prime. cachedConstants = { initial: Object.freeze(primes.slice(0, 8).map((prime) => fractionalRootWord(prime, 2))), round: Object.freeze(primes.map((prime) => fractionalRootWord(prime, 3))), }; } return cachedConstants; } /** FIPS 180-4 §§4.2.2 and 5.3.3: public constants derived exactly from prime roots. */ export function deriveSha256Constants(): Sha256Constants { const { initial, round } = constantWords(); return { initialWords: initial.map(hexWord), roundConstants: round.map(hexWord) }; } function rotateRight(word: number, bits: number): number { return (word >>> bits) | (word << (32 - bits)); } /** * Full 64-round SHA-256 trace for one padded block (at most 55 UTF-8 bytes). * FIPS 180-4 §§5.1.1, 5.2.1 and 6.2.2. This is an inspectable teaching implementation, * not a cryptographic library or a NIST-validated cryptographic module. */ export function sha256Trace(text: string): Sha256Trace { if (typeof text !== 'string') throw new TypeError('Expected a UTF-8 text input'); // A UTF-8 encoding cannot be shorter than this UTF-16 code-unit count. if (text.length > 55) throw new RangeError('The single-block trace accepts at most 55 UTF-8 bytes'); const input = new TextEncoder().encode(text); if (input.length > 55) throw new RangeError('The single-block trace accepts at most 55 UTF-8 bytes'); const padded = new Uint8Array(64); padded.set(input); padded[input.length] = 0x80; const view = new DataView(padded.buffer); // The upper 32 bits of the 64-bit length stay zero under the 55-byte bound. view.setUint32(60, input.length * 8, false); const schedule = new Array(64); for (let round = 0; round < 16; round++) schedule[round] = view.getUint32(round * 4, false); for (let round = 16; round < 64; round++) { const earlier = schedule[round - 15]; const later = schedule[round - 2]; const sigma0 = rotateRight(earlier, 7) ^ rotateRight(earlier, 18) ^ (earlier >>> 3); const sigma1 = rotateRight(later, 17) ^ rotateRight(later, 19) ^ (later >>> 10); schedule[round] = (schedule[round - 16] + sigma0 + schedule[round - 7] + sigma1) >>> 0; } const { initial, round: constants } = constantWords(); let [a, b, c, d, e, f, g, h] = initial; const rounds: Sha256Trace['rounds'] = []; for (let round = 0; round < 64; round++) { const sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25); const choose = (e & f) ^ (~e & g); const temporary1 = (h + sum1 + choose + constants[round] + schedule[round]) >>> 0; const sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22); const majority = (a & b) ^ (a & c) ^ (b & c); const temporary2 = (sum0 + majority) >>> 0; [a, b, c, d, e, f, g, h] = [ (temporary1 + temporary2) >>> 0, a, b, c, (d + temporary1) >>> 0, e, f, g, ]; rounds.push({ round, words: [a, b, c, d, e, f, g, h].map(hexWord) }); } const digest = [a, b, c, d, e, f, g, h].map((word, index) => hexWord(word + initial[index])).join(''); const hexBytes = (bytes: Uint8Array) => Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); return { inputHex: hexBytes(input), paddedHex: hexBytes(padded), initialWords: initial.map(hexWord), roundConstants: constants.map(hexWord), schedule: schedule.map(hexWord), rounds, digest, }; }