d35c27a38d5e
rs.ts: Reed-Solomon GF(2^8) RS(255,178) block codec with Berlekamp-Massey + Forney; corrects up to 38 byte errors per block (30.2% ECC); 8 round-trip tests pass
- time
- 2026-05-01 04:45 UTC · 5 months ago
- machine
- koh2
- parent
- caebcae786e5
↓ peach-browser-d35c27a3.tar.gzatlas at this save
files changed2 files
src/crypto/rs.test.ts+84 −0src/crypto/rs.ts+328 −0src/crypto/rs.test.ts+84 −0
@@ -0,0 +1,84 @@1+import { describe, it, expect } from "vitest";2+import {3+ reedSolomonEncode,4+ reedSolomonDecode,5+ RS_BLOCK_DATA,6+ RS_BLOCK_PARITY,7+ RS_BLOCK_TOTAL,8+} from "./rs";9+10+function randomBytes(n: number): Uint8Array {11+ const out = new Uint8Array(n);12+ for (let i = 0; i < n; i++) out[i] = (Math.random() * 256) | 0;13+ return out;14+}15+16+function corruptBytes(buf: Uint8Array, n: number): Uint8Array {17+ const out = new Uint8Array(buf);18+ const positions = new Set<number>();19+ while (positions.size < n) {20+ positions.add((Math.random() * buf.length) | 0);21+ }22+ for (const p of positions) out[p] ^= ((Math.random() * 255) | 0) + 1;23+ return out;24+}25+26+describe("Reed-Solomon RS(255,178)", () => {27+ it("constants are coherent", () => {28+ expect(RS_BLOCK_DATA + RS_BLOCK_PARITY).toBe(RS_BLOCK_TOTAL);29+ expect(RS_BLOCK_TOTAL).toBe(255);30+ });31+32+ it("round-trips a small payload (no errors)", () => {33+ const data = new Uint8Array([0x4d, 0x01, 0x05, 0x00, 1, 2, 3, 4, 5]);34+ const encoded = reedSolomonEncode(data);35+ expect(encoded.length).toBe(RS_BLOCK_TOTAL); // single block36+ const decoded = reedSolomonDecode(encoded, data.length);37+ expect(Array.from(decoded)).toEqual(Array.from(data));38+ });39+40+ it("round-trips a multi-block payload (no errors)", () => {41+ const data = randomBytes(RS_BLOCK_DATA * 3 + 17);42+ const encoded = reedSolomonEncode(data);43+ expect(encoded.length).toBe(RS_BLOCK_TOTAL * 4); // 4 blocks44+ const decoded = reedSolomonDecode(encoded, data.length);45+ expect(Array.from(decoded)).toEqual(Array.from(data));46+ });47+48+ it("corrects 1 byte error per block", () => {49+ const data = randomBytes(RS_BLOCK_DATA);50+ const encoded = reedSolomonEncode(data);51+ const corrupted = corruptBytes(encoded, 1);52+ const decoded = reedSolomonDecode(corrupted, data.length);53+ expect(Array.from(decoded)).toEqual(Array.from(data));54+ });55+56+ it("corrects up to floor(parity/2) byte errors per block", () => {57+ const data = randomBytes(RS_BLOCK_DATA);58+ const encoded = reedSolomonEncode(data);59+ // RS_BLOCK_PARITY=77, can correct up to 38 errors per block60+ const corrupted = corruptBytes(encoded, 38);61+ const decoded = reedSolomonDecode(corrupted, data.length);62+ expect(Array.from(decoded)).toEqual(Array.from(data));63+ });64+65+ it("fails when more than floor(parity/2) errors per block", () => {66+ const data = randomBytes(RS_BLOCK_DATA);67+ const encoded = reedSolomonEncode(data);68+ const corrupted = corruptBytes(encoded, 50); // > 3869+ expect(() => reedSolomonDecode(corrupted, data.length)).toThrow(/uncorrectable/);70+ });71+72+ it("handles empty payload", () => {73+ const encoded = reedSolomonEncode(new Uint8Array(0));74+ expect(encoded.length).toBe(0);75+ const decoded = reedSolomonDecode(encoded, 0);76+ expect(decoded.length).toBe(0);77+ });78+79+ it("rejects malformed received length", () => {80+ expect(() =>81+ reedSolomonDecode(new Uint8Array(RS_BLOCK_TOTAL + 1), 10),82+ ).toThrow(/not a multiple/);83+ });84+});src/crypto/rs.ts+328 −0
@@ -0,0 +1,328 @@1+/**2+ * Reed-Solomon codec — GF(2^8) with primitive polynomial 0x11D3+ * (x^8 + x^4 + x^3 + x^2 + 1, the standard CCSDS / NASA polynomial).4+ *5+ * Block-based RS(255, 178): 178 data bytes + 77 parity bytes per block.6+ * Yields 30.2% ECC, matching the Mayan spec's ECC_RATE = 0.30.7+ *8+ * The Mayan spec (peach_mayan_spec.md §4) cites "30% ECC" and9+ * "RS(255,223)" in the same sentence, but those numbers contradict —10+ * RS(255,223) is 12.5% ECC. The encoder code in the spec uses11+ * ECC_RATE = 0.30 consistently, so we honor the rate (30%) and pick12+ * standard 255-byte blocks at the matching ratio.13+ *14+ * The exposed API matches the call sites in the Mayan spec:15+ *16+ * reedSolomonEncode(data, totalParityBytes)17+ * reedSolomonDecode(received, dataBytes, parityBytes)18+ *19+ * `totalParityBytes` is the spec's `Math.ceil(data.length * ECC_RATE)`;20+ * we treat it as advisory and instead generate 77 parity bytes per21+ * 178-byte block so the symbol-size math matches what the spec expected.22+ */23+24+const FIELD_SIZE = 256;25+const PRIMITIVE_POLY = 0x11d;26+27+const BLOCK_DATA = 178;28+const BLOCK_PARITY = 77;29+const BLOCK_TOTAL = BLOCK_DATA + BLOCK_PARITY; // 25530+31+// ── GF(2^8) tables ──────────────────────────────────────────────────────────32+33+const EXP_TABLE = new Uint8Array(FIELD_SIZE * 2);34+const LOG_TABLE = new Uint8Array(FIELD_SIZE);35+36+(function initTables() {37+ let x = 1;38+ for (let i = 0; i < 255; i++) {39+ EXP_TABLE[i] = x;40+ LOG_TABLE[x] = i;41+ x <<= 1;42+ if (x & 0x100) x ^= PRIMITIVE_POLY;43+ }44+ // Double-length exp table avoids modulo on the hot path.45+ for (let i = 255; i < 512; i++) EXP_TABLE[i] = EXP_TABLE[i - 255];46+})();47+48+function gfMul(a: number, b: number): number {49+ if (a === 0 || b === 0) return 0;50+ return EXP_TABLE[LOG_TABLE[a] + LOG_TABLE[b]];51+}52+53+function gfDiv(a: number, b: number): number {54+ if (b === 0) throw new Error("rs: divide by zero");55+ if (a === 0) return 0;56+ return EXP_TABLE[(LOG_TABLE[a] + 255 - LOG_TABLE[b]) % 255];57+}58+59+function gfPow(x: number, power: number): number {60+ if (x === 0) return 0;61+ return EXP_TABLE[(LOG_TABLE[x] * power) % 255];62+}63+64+function gfInv(x: number): number {65+ if (x === 0) throw new Error("rs: inverse of zero");66+ return EXP_TABLE[(255 - LOG_TABLE[x]) % 255];67+}68+69+// ── Polynomial helpers (coefficients high→low; out[0] = x^(n-1)) ────────────70+71+function polyScale(p: Uint8Array, x: number): Uint8Array {72+ const r = new Uint8Array(p.length);73+ for (let i = 0; i < p.length; i++) r[i] = gfMul(p[i], x);74+ return r;75+}76+77+function polyAdd(p: Uint8Array, q: Uint8Array): Uint8Array {78+ const len = Math.max(p.length, q.length);79+ const r = new Uint8Array(len);80+ for (let i = 0; i < p.length; i++) r[i + len - p.length] = p[i];81+ for (let i = 0; i < q.length; i++) r[i + len - q.length] ^= q[i];82+ return r;83+}84+85+function polyMul(p: Uint8Array, q: Uint8Array): Uint8Array {86+ const r = new Uint8Array(p.length + q.length - 1);87+ for (let j = 0; j < q.length; j++) {88+ if (q[j] === 0) continue;89+ for (let i = 0; i < p.length; i++) {90+ if (p[i] === 0) continue;91+ r[i + j] ^= gfMul(p[i], q[j]);92+ }93+ }94+ return r;95+}96+97+function polyEval(p: Uint8Array, x: number): number {98+ let y = p[0];99+ for (let i = 1; i < p.length; i++) y = gfMul(y, x) ^ p[i];100+ return y;101+}102+103+function generatorPoly(parity: number): Uint8Array {104+ let g: Uint8Array = new Uint8Array([1]);105+ for (let i = 0; i < parity; i++) {106+ g = polyMul(g, new Uint8Array([1, EXP_TABLE[i]]));107+ }108+ return g;109+}110+111+// Cache generators per parity length — only one is used in practice.112+const generatorCache = new Map<number, Uint8Array>();113+function getGenerator(parity: number): Uint8Array {114+ let g = generatorCache.get(parity);115+ if (!g) {116+ g = generatorPoly(parity);117+ generatorCache.set(parity, g);118+ }119+ return g;120+}121+122+// ── Block encode/decode ─────────────────────────────────────────────────────123+124+function encodeBlock(data: Uint8Array, parity: number): Uint8Array {125+ const gen = getGenerator(parity);126+ const out = new Uint8Array(data.length + parity);127+ out.set(data);128+ for (let i = 0; i < data.length; i++) {129+ const coef = out[i];130+ if (coef !== 0) {131+ for (let j = 1; j < gen.length; j++) {132+ out[i + j] ^= gfMul(gen[j], coef);133+ }134+ }135+ }136+ // First `data.length` bytes still hold the original message; replace with137+ // it (it was used as scratch above) and copy the parity tail unchanged.138+ out.set(data);139+ return out;140+}141+142+function calcSyndromes(received: Uint8Array, parity: number): Uint8Array {143+ const synd = new Uint8Array(parity + 1);144+ synd[0] = 0;145+ for (let i = 0; i < parity; i++) {146+ synd[i + 1] = polyEval(received, EXP_TABLE[i]);147+ }148+ return synd;149+}150+151+function findErrorLocator(synd: Uint8Array, parity: number): Uint8Array | null {152+ let errLoc: Uint8Array = new Uint8Array([1]);153+ let oldLoc: Uint8Array = new Uint8Array([1]);154+155+ for (let i = 0; i < parity; i++) {156+ oldLoc = new Uint8Array([...oldLoc, 0]);157+ let delta = synd[i + 1];158+ for (let j = 1; j < errLoc.length; j++) {159+ delta ^= gfMul(errLoc[errLoc.length - 1 - j], synd[i + 1 - j]);160+ }161+ if (delta !== 0) {162+ if (oldLoc.length > errLoc.length) {163+ const newLoc = polyScale(oldLoc, delta);164+ oldLoc = polyScale(errLoc, gfInv(delta));165+ errLoc = newLoc;166+ }167+ errLoc = polyAdd(errLoc, polyScale(oldLoc, delta));168+ }169+ }170+171+ // Strip leading zeros172+ let lead = 0;173+ while (lead < errLoc.length && errLoc[lead] === 0) lead++;174+ errLoc = errLoc.slice(lead);175+176+ const errs = errLoc.length - 1;177+ if (errs * 2 > parity) return null; // too many errors178+ return errLoc;179+}180+181+function findErrors(errLoc: Uint8Array, length: number): number[] | null {182+ // Λ has roots at α^(-poly_degree). Chien finds Λ(α^j)=0 at j = (b+1) mod 255183+ // for byte index b in a length-255 codeword. So b = (j - 1) mod 255.184+ // For our use this is always full RS(255,178) blocks; the modulus is 255.185+ const errs = errLoc.length - 1;186+ const positions: number[] = [];187+ const N = 255;188+ for (let i = 0; i < N; i++) {189+ if (polyEval(errLoc, gfPow(2, i)) === 0) {190+ const byteIdx = (i - 1 + N) % N;191+ if (byteIdx < length) positions.push(byteIdx);192+ }193+ }194+ if (positions.length !== errs) return null;195+ return positions;196+}197+198+function correctErrors(199+ received: Uint8Array,200+ synd: Uint8Array,201+ errPositions: number[],202+): Uint8Array {203+ const coefPos = errPositions.map((p) => received.length - 1 - p);204+ // Forney syndrome205+ const errLocPoly = (() => {206+ let p: Uint8Array = new Uint8Array([1]);207+ for (const pos of coefPos) {208+ p = polyMul(p, polyAdd(new Uint8Array([1]), new Uint8Array([gfPow(2, pos), 0])));209+ }210+ return p;211+ })();212+213+ const syndRev = new Uint8Array(synd.length - 1);214+ for (let i = 0; i < syndRev.length; i++) syndRev[i] = synd[synd.length - 1 - i];215+ let omega: Uint8Array = polyMul(syndRev, errLocPoly);216+ omega = omega.slice(omega.length - errLocPoly.length + 1);217+218+ // Formal derivative of errLocPoly (skip even-degree terms)219+ const errLocDeriv = new Uint8Array(errLocPoly.length - 1);220+ for (let i = 0; i < errLocDeriv.length; i++) {221+ const deg = errLocPoly.length - 1 - i;222+ if (deg % 2 === 1) errLocDeriv[i] = errLocPoly[i];223+ }224+225+ const out = new Uint8Array(received);226+ for (let i = 0; i < coefPos.length; i++) {227+ const pos = coefPos[i];228+ const xi = gfPow(2, pos);229+ const xiInv = gfInv(xi);230+ const num = polyEval(omega, xiInv);231+ const den = polyEval(errLocDeriv, xiInv);232+ if (den === 0) throw new Error("rs: denominator zero in Forney");233+ const magnitude = gfMul(xi, gfDiv(num, den));234+ out[received.length - 1 - pos] ^= magnitude;235+ }236+ return out;237+}238+239+function decodeBlock(received: Uint8Array, parity: number): Uint8Array | null {240+ const synd = calcSyndromes(received, parity);241+ let hasError = false;242+ for (let i = 1; i < synd.length; i++) {243+ if (synd[i] !== 0) { hasError = true; break; }244+ }245+ if (!hasError) return received.slice(0, received.length - parity);246+247+ const errLoc = findErrorLocator(synd, parity);248+ if (!errLoc) return null;249+250+ const positions = findErrors(errLoc, received.length);251+ if (!positions) return null;252+253+ let corrected: Uint8Array;254+ try {255+ corrected = correctErrors(received, synd, positions);256+ } catch {257+ return null;258+ }259+260+ // Verify by recomputing syndromes261+ const synd2 = calcSyndromes(corrected, parity);262+ for (let i = 1; i < synd2.length; i++) {263+ if (synd2[i] !== 0) return null;264+ }265+ return corrected.slice(0, received.length - parity);266+}267+268+// ── Public block-mode API ───────────────────────────────────────────────────269+270+/**271+ * Encode `data` with Reed-Solomon. Splits into BLOCK_DATA-byte data blocks272+ * (zero-padded), appends BLOCK_PARITY parity bytes per block, returns the273+ * concatenation. `_advisoryParityBytes` is accepted for spec compatibility274+ * and ignored — the actual parity volume is fixed by the block geometry.275+ */276+export function reedSolomonEncode(277+ data: Uint8Array,278+ _advisoryParityBytes?: number,279+): Uint8Array {280+ if (data.length === 0) return new Uint8Array(0);281+ const blocks = Math.ceil(data.length / BLOCK_DATA);282+ const out = new Uint8Array(blocks * BLOCK_TOTAL);283+ for (let b = 0; b < blocks; b++) {284+ const start = b * BLOCK_DATA;285+ const slice = data.slice(start, start + BLOCK_DATA);286+ const padded = slice.length === BLOCK_DATA287+ ? slice288+ : (() => { const p = new Uint8Array(BLOCK_DATA); p.set(slice); return p; })();289+ const enc = encodeBlock(padded, BLOCK_PARITY);290+ out.set(enc, b * BLOCK_TOTAL);291+ }292+ return out;293+}294+295+/**296+ * Decode an RS-encoded stream. Splits into BLOCK_TOTAL-byte received blocks297+ * and decodes each. `dataBytes` is the original payload length; the result298+ * is trimmed accordingly. Throws if any block fails (too many errors).299+ *300+ * `_parityBytes` is accepted for spec compatibility; the per-block parity301+ * count is fixed by the block geometry.302+ */303+export function reedSolomonDecode(304+ received: Uint8Array,305+ dataBytes: number,306+ _parityBytes?: number,307+): Uint8Array {308+ if (dataBytes === 0) return new Uint8Array(0);309+ if (received.length % BLOCK_TOTAL !== 0) {310+ throw new Error(311+ `rs: received length ${received.length} not a multiple of ${BLOCK_TOTAL}`,312+ );313+ }314+ const blocks = received.length / BLOCK_TOTAL;315+ const out = new Uint8Array(blocks * BLOCK_DATA);316+ for (let b = 0; b < blocks; b++) {317+ const slice = received.slice(b * BLOCK_TOTAL, (b + 1) * BLOCK_TOTAL);318+ const decoded = decodeBlock(slice, BLOCK_PARITY);319+ if (!decoded) throw new Error(`rs: block ${b} uncorrectable`);320+ out.set(decoded, b * BLOCK_DATA);321+ }322+ return out.slice(0, dataBytes);323+}324+325+/** Constants the Mayan layer needs for capacity math. */326+export const RS_BLOCK_DATA = BLOCK_DATA;327+export const RS_BLOCK_PARITY = BLOCK_PARITY;328+export const RS_BLOCK_TOTAL = BLOCK_TOTAL;