4e4dbfb98154
mayan tests: 10 round-trip + RS recovery tests pass (5/100/500/2000-byte payloads, multi-block, magic-byte rejection, deterministic 0.5% bit-flip recovery)
- time
- 2026-05-01 04:51 UTC · 5 months ago
- machine
- koh2
- parent
- 561a5913550a
↓ peach-browser-4e4dbfb9.tar.gzatlas at this save
files changed2 files
src/crypto/mayan.test.ts+138 −0src/crypto/mayan.ts+12 −3src/crypto/mayan.test.ts+138 −0
@@ -0,0 +1,138 @@1+import { describe, it, expect } from "vitest";2+import {3+ MAYAN_MAGIC,4+ MAYAN_VERSION,5+ MAX_PAYLOAD_BYTES,6+ RUNE_ZONE,7+ QUIET_ZONE,8+ calculateSymbolSize,9+ encodeMayanMatrix,10+ decodeMatrix,11+ isReserved,12+} from "./mayan";13+14+// Deterministic PRNG — Mulberry32 — so the recovery test is reproducible.15+function mulberry32(seed: number) {16+ let state = seed >>> 0;17+ return () => {18+ state = (state + 0x6d2b79f5) >>> 0;19+ let t = state;20+ t = Math.imul(t ^ (t >>> 15), t | 1);21+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);22+ return ((t ^ (t >>> 14)) >>> 0) / 0x100000000;23+ };24+}25+26+function randomPayload(n: number, seed = 1): Uint8Array {27+ const rng = mulberry32(seed);28+ const out = new Uint8Array(n);29+ for (let i = 0; i < n; i++) out[i] = (rng() * 256) | 0;30+ return out;31+}32+33+function flipRandomDataBits(34+ matrix: boolean[][],35+ size: number,36+ fraction: number,37+ seed = 1,38+): boolean[][] {39+ const center = Math.floor(size / 2);40+ const dataPositions: { r: number; c: number }[] = [];41+ for (let r = QUIET_ZONE; r < size - QUIET_ZONE; r++) {42+ for (let c = QUIET_ZONE; c < size - QUIET_ZONE; c++) {43+ if (!isReserved(r, c, center, size)) dataPositions.push({ r, c });44+ }45+ }46+ const rng = mulberry32(seed);47+ const out = matrix.map((row) => row.slice());48+ const toFlip = Math.floor(dataPositions.length * fraction);49+ for (let i = 0; i < toFlip; i++) {50+ const j = i + Math.floor(rng() * (dataPositions.length - i));51+ [dataPositions[i], dataPositions[j]] = [dataPositions[j], dataPositions[i]];52+ const { r, c } = dataPositions[i];53+ out[r][c] = !out[r][c];54+ }55+ return out;56+}57+58+describe("Mayan format", () => {59+ it("constants are coherent", () => {60+ expect(MAYAN_MAGIC).toBe(0x4d);61+ expect(MAYAN_VERSION).toBe(0x01);62+ expect(RUNE_ZONE).toBe(27);63+ expect(QUIET_ZONE).toBe(2);64+ });65+66+ it("calculateSymbolSize returns >=64 for tiny payloads", () => {67+ expect(calculateSymbolSize(0)).toBe(64);68+ expect(calculateSymbolSize(50)).toBeGreaterThanOrEqual(64);69+ });70+71+ it("calculateSymbolSize grows with payload", () => {72+ const small = calculateSymbolSize(100);73+ const big = calculateSymbolSize(5000);74+ expect(big).toBeGreaterThan(small);75+ });76+77+ it("rejects payloads larger than MAX_PAYLOAD_BYTES", () => {78+ const oversize = new Uint8Array(MAX_PAYLOAD_BYTES + 1);79+ expect(() => encodeMayanMatrix(oversize)).toThrow(/payload too large/);80+ });81+});82+83+describe("Mayan round-trip", () => {84+ it("round-trips a 5-byte payload", () => {85+ const payload = new Uint8Array([1, 2, 3, 4, 5]);86+ const { matrix, size } = encodeMayanMatrix(payload);87+ const decoded = decodeMatrix(matrix, size);88+ expect(decoded).not.toBeNull();89+ expect(Array.from(decoded!)).toEqual(Array.from(payload));90+ });91+92+ it("round-trips a 100-byte random payload", () => {93+ const payload = randomPayload(100);94+ const { matrix, size } = encodeMayanMatrix(payload);95+ const decoded = decodeMatrix(matrix, size);96+ expect(decoded).not.toBeNull();97+ expect(Array.from(decoded!)).toEqual(Array.from(payload));98+ });99+100+ it("round-trips a 500-byte payload (multi-block RS)", () => {101+ const payload = randomPayload(500);102+ const { matrix, size } = encodeMayanMatrix(payload);103+ const decoded = decodeMatrix(matrix, size);104+ expect(decoded).not.toBeNull();105+ expect(Array.from(decoded!)).toEqual(Array.from(payload));106+ });107+108+ it("round-trips a 2000-byte payload", () => {109+ const payload = randomPayload(2000);110+ const { matrix, size } = encodeMayanMatrix(payload);111+ const decoded = decodeMatrix(matrix, size);112+ expect(decoded).not.toBeNull();113+ expect(Array.from(decoded!)).toEqual(Array.from(payload));114+ });115+116+ it("rejects a matrix with no Mayan magic byte", () => {117+ const fake: boolean[][] = Array.from({ length: 64 }, () =>118+ new Array<boolean>(64).fill(false),119+ );120+ const decoded = decodeMatrix(fake, 64);121+ expect(decoded).toBeNull();122+ });123+});124+125+describe("Mayan RS recovery", () => {126+ // Recoverable threshold for RS(255,178): up to 38 byte errors per block.127+ // Each byte holds 8 bits, so the bit-flip equivalent is bounded by the128+ // byte coverage P(byte has ≥1 flip) = 1 - (1-p)^8. For 30% byte errors129+ // (within RS limit), p ≈ 0.043 — so 1% bit flips is comfortably inside.130+ it("recovers from 0.5% random bit flips in data area", () => {131+ const payload = randomPayload(50);132+ const { matrix, size } = encodeMayanMatrix(payload);133+ const corrupted = flipRandomDataBits(matrix, size, 0.005, 42);134+ const decoded = decodeMatrix(corrupted, size);135+ expect(decoded).not.toBeNull();136+ expect(Array.from(decoded!)).toEqual(Array.from(payload));137+ });138+});src/crypto/mayan.ts+12 −3
@@ -16,7 +16,12 @@1616 * Othalan ᛟ at its center. Do not replace with QR/Aztec/etc.1717 */1818 19-import { reedSolomonEncode, reedSolomonDecode } from "./rs";19+import {20+ reedSolomonEncode,21+ reedSolomonDecode,22+ RS_BLOCK_DATA,23+ RS_BLOCK_TOTAL,24+} from "./rs";2025 2126 export const MAYAN_MAGIC = 0x4d;2227 export const MAYAN_VERSION = 0x01;@@ -37,8 +42,12 @@3742 export function calculateSymbolSize(payloadBytes: number): number {3843 const headerBytes = 4;3944 const totalData = payloadBytes + headerBytes;40- const withECC = totalData / (1 - ECC_RATE);41- const dataBits = Math.ceil(withECC * 8);45+ // Use the actual RS-encoded byte count (block-padded) instead of the46+ // theoretical ECC_RATE estimate. The RS codec rounds up to whole 178-byte47+ // data blocks and emits 255 bytes per block, so smaller payloads have48+ // higher effective overhead than the 30% nominal rate.49+ const blocks = Math.max(1, Math.ceil(totalData / RS_BLOCK_DATA));50+ const dataBits = blocks * RS_BLOCK_TOTAL * 8;4251 4352 for (let size = 64; size <= 600; size++) {4453 const totalModules = size * size;