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
4 files manifest.firefox.json+1 −1manifest.json+1 −1src/crypto/mayan.test.ts+70 −0src/crypto/mayan.ts+40 −19
manifest.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": {

view file

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",

view file

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 

view file

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(

view file