36d1d3899c98
fix Mayan image-decode failure: rune scan was eating data dark blobs adjacent to rune left vertex; switched findOthalan to sliding-window peek + multi-row averaging
- time
- 2026-05-01 17:34 UTC · 5 months ago
- machine
- koh2
- parent
- 561b656d9465
↓ peach-browser-36d1d389.tar.gzatlas at this save
files changed4 files
manifest.firefox.json+1 −1manifest.json+1 −1src/crypto/mayan.test.ts+70 −0src/crypto/mayan.ts+40 −19manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.23.0",4+ "version": "2.24.0",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.23.0",4+ "version": "2.24.0",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",src/crypto/mayan.test.ts+70 −0
@@ -8,9 +8,41 @@88 calculateSymbolSize,99 encodeMayanMatrix,1010 decodeMatrix,11+ decodeMayan,1112 isReserved,1213 } from "./mayan";1314 15+// Synthesize an ImageData from a boolean module matrix at a given module pixel16+// size. Pure black/white, no border padding. Useful for exercising the full17+// image-decode pipeline without a canvas.18+function matrixToImageData(19+ matrix: boolean[][],20+ size: number,21+ modulePx: number,22+): ImageData {23+ const W = size * modulePx;24+ const H = size * modulePx;25+ const data = new Uint8ClampedArray(W * H * 4);26+ for (let r = 0; r < size; r++) {27+ for (let c = 0; c < size; c++) {28+ const dark = matrix[r][c];29+ const v = dark ? 0 : 255;30+ for (let dy = 0; dy < modulePx; dy++) {31+ for (let dx = 0; dx < modulePx; dx++) {32+ const px = c * modulePx + dx;33+ const py = r * modulePx + dy;34+ const i = (py * W + px) * 4;35+ data[i] = v;36+ data[i + 1] = v;37+ data[i + 2] = v;38+ data[i + 3] = 255;39+ }40+ }41+ }42+ }43+ return { data, width: W, height: H, colorSpace: "srgb" } as ImageData;44+}45+1446 // Deterministic PRNG — Mulberry32 — so the recovery test is reproducible.1547 function mulberry32(seed: number) {1648 let state = seed >>> 0;@@ -119,6 +151,44 @@119151 );120152 const decoded = decodeMatrix(fake, 64);121153 expect(decoded).toBeNull();154+ });155+});156+157+describe("Mayan image pipeline", () => {158+ it("decodes a 5-byte payload via the full image pipeline at modulePx=3", () => {159+ const payload = new Uint8Array([1, 2, 3, 4, 5]);160+ const { matrix, size } = encodeMayanMatrix(payload);161+ const imgData = matrixToImageData(matrix, size, 3);162+ const decoded = decodeMayan(imgData);163+ expect(decoded).not.toBeNull();164+ expect(Array.from(decoded!)).toEqual(Array.from(payload));165+ });166+167+ it("decodes a 100-byte payload via the full image pipeline at modulePx=3", () => {168+ const payload = randomPayload(100);169+ const { matrix, size } = encodeMayanMatrix(payload);170+ const imgData = matrixToImageData(matrix, size, 3);171+ const decoded = decodeMayan(imgData);172+ expect(decoded).not.toBeNull();173+ expect(Array.from(decoded!)).toEqual(Array.from(payload));174+ });175+176+ it("decodes a 100-byte payload via the full image pipeline at modulePx=4", () => {177+ const payload = randomPayload(100);178+ const { matrix, size } = encodeMayanMatrix(payload);179+ const imgData = matrixToImageData(matrix, size, 4);180+ const decoded = decodeMayan(imgData);181+ expect(decoded).not.toBeNull();182+ expect(Array.from(decoded!)).toEqual(Array.from(payload));183+ });184+185+ it("decodes a 500-byte payload via the full image pipeline at modulePx=3", () => {186+ const payload = randomPayload(500);187+ const { matrix, size } = encodeMayanMatrix(payload);188+ const imgData = matrixToImageData(matrix, size, 3);189+ const decoded = decodeMayan(imgData);190+ expect(decoded).not.toBeNull();191+ expect(Array.from(decoded!)).toEqual(Array.from(payload));122192 });123193 });124194 src/crypto/mayan.ts+40 −19
@@ -428,7 +428,14 @@428428 H: number,429429 ): { cx: number; cy: number; size: number } | null {430430 // Scan horizontally for a dark-light-dark pattern with the diamond's431- // expected proportions: each side ~3 modules, interior ≥ 1.5× side.431+ // expected proportions: each side ~4-5 modules wide, light interior ≥1.5×.432+ //433+ // Sliding-window: each dark blob is considered as a potential darkStart.434+ // We peek light + dark2 ahead but only consume dark1 — so on a failed435+ // match the next iteration's darkStart slides forward to dark2's position436+ // (instead of leaping past it). This is critical because the data area437+ // immediately left of the rune can contain dark blobs that would otherwise438+ // eat the rune's left vertex on a non-sliding scan.432439 const candidates: { cx: number; cy: number; size: number }[] = [];433440 434441 const yStart = Math.floor(H * 0.2);@@ -442,14 +449,15 @@442449 while (x < W && binary[y][x]) x++;443450 const darkLen = x - darkStart;444451 445- const lightStart = x;446- while (x < W && !binary[y][x]) x++;447- const lightLen = x - lightStart;452+ // Peek ahead: light run, then next dark run. Don't consume them — the453+ // next loop iteration will re-skip the light and treat dark2 as dark1.454+ let p = x;455+ while (p < W && !binary[y][p]) p++;456+ const lightLen = p - x;457+ const dark2Start = p;458+ while (p < W && binary[y][p]) p++;459+ const dark2Len = p - dark2Start;448460 449- const dark2Start = x;450- while (x < W && binary[y][x]) x++;451- const dark2Len = x - dark2Start;452-453461 if (454462 darkLen >= 4 &&455463 dark2Len >= 4 &&@@ -457,25 +465,38 @@457465 lightLen > darkLen * 1.5 &&458466 lightLen < darkLen * 12459467 ) {460- const cx = Math.floor((darkStart + x) / 2);461- const size = x - darkStart;468+ const cx = Math.floor((darkStart + p) / 2);469+ const size = p - darkStart;462470 candidates.push({ cx, cy: y, size });463471 }472+ // x stays at end of dark1; loop's skip-light advances us into dark2.464473 }465474 }466475 467476 if (candidates.length < 3) return null;468477 469- // Cluster by (cx, size) proximity and pick the strongest cluster.470- const best = candidates.reduce((a, b) => (a.size > b.size ? a : b));478+ // The rune diamond's three widest scan rows (rune-rows 9, 10, 11) all match479+ // the dark-light-dark pattern with size = 23 modules. Pick all candidates480+ // tied within ~1 module of the global max, then average their cy/cx to481+ // recover the diamond's geometric center robustly.482+ const maxSize = candidates.reduce((m, c) => Math.max(m, c.size), 0);483+ const modulePx = Math.max(1, Math.round(maxSize / 23));484+ const top = candidates.filter((c) => c.size >= maxSize - modulePx);485+ let sumCx = 0;486+ let sumCy = 0;487+ for (const c of top) { sumCx += c.cx; sumCy += c.cy; }488+ const cxAvg = sumCx / top.length;489+ const cyAvg = sumCy / top.length;471490 472- // best.cy is the scan row where the widest dark-light-dark was found, which473- // is the diamond's horizontal midpoint (the LEFT/RIGHT vertex row). This row474- // is always 1 module above the code-grid center (drawOthalan places the475- // vertices at centerRow-1). Shift down 1 module so sampleModuleGrid uses476- // the correct center reference.477- const modulePx = Math.round(best.size / 23);478- return { cx: best.cx, cy: best.cy + modulePx, size: best.size };491+ // The averaged cy lands on the diamond's geometric vertical center492+ // (rune-row 10, the LEFT/RIGHT vertex row). The code-grid center is at493+ // rune-row 11 — exactly one module below — because drawOthalan offsets494+ // the diamond up by one to make room for the legs. Shift down 1 module.495+ return {496+ cx: Math.round(cxAvg),497+ cy: Math.round(cyAvg + modulePx),498+ size: maxSize,499+ };479500 }480501 481502 function estimateModuleSize(