a8f9afdd5aa7
remove all Mayan code (encoder, decoder, RS, peachQR, restore tab, export panel, CSS); free up mobile→desktop transfer (drop Pro gate on START_PAIRING_SERVER and ACCEPT_PAIRING); convert remaining SW dynamic imports to static so vite preload helper never fires off-document; restore screen no longer pops out to a detached window; onboarding back button on cards 2-3; refine Two keys copy to acknowledge password reset via recovery words; Pro list now mentions advanced vault health (v2.34.0)
- time
- 2026-05-07 01:31 UTC · 5 months ago
- machine
- koh2
- parent
- 7d40cd620602
↓ peach-browser-a8f9afdd.tar.gzatlas at this save
files changed13 files
manifest.firefox.json+1 −1manifest.json+1 −1src/background/messages.ts+19 −21src/crypto/mayan.test.ts+0 −281src/crypto/mayan.ts+0 −413src/crypto/mayan_decode.ts+0 −659src/crypto/peachQR.ts+0 −73src/crypto/rs.test.ts+0 −84src/crypto/rs.ts+0 −340src/popup/styles/popup.css+26 −60src/popup/views/Export.tsx+1 −167src/popup/views/Lock.tsx+7 −232src/popup/views/Onboarding.tsx+25 −3manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach",4- "version": "2.33.7",4+ "version": "2.34.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.33.7",4+ "version": "2.34.0",55 "description": "Zero-knowledge password manager",66 "action": {77 "default_popup": "src/popup/index.html",src/background/messages.ts+19 −21
@@ -36,8 +36,24 @@3636 setPasskeyProviderEnabled,3737 } from "./passkey";3838 import { buildAdvancedHealthReport, resolveResetUrl } from "./health";39-import { storageClear } from "../shared/storage";39+import { storageClear, storageGet } from "../shared/storage";4040 import { findInvalidWords } from "../shared/recovery-words";41+import {42+ addPairedDevice,43+ addTrustedNetwork,44+ clearIncomingPairingRequest,45+ getIncomingPairingRequest,46+ getPairedDevices,47+ getTrustedNetworks,48+ removePairedDevice,49+ removeTrustedNetwork,50+ setIncomingPairingRequest,51+} from "./sync-storage";52+import { getCurrentNetwork, isOnTailscale } from "./sync-network";53+import {54+ consumeRecentPairing,55+ setActivePairingToken,56+} from "./pairing-server-state";4157 import type {4258 Message,4359 MessageResponse,@@ -637,10 +653,6 @@637653 }638654 }639655 case "GET_SYNC_STATE": {640- const { getPairedDevices, getTrustedNetworks } = await import('./sync-storage');641- const { isOnTailscale } = await import('./sync-network');642- const { isProUser } = await import('./license');643- const { storageGet } = await import('../shared/storage');644656 const pairedDevices = await getPairedDevices();645657 const trustedNetworks = await getTrustedNetworks();646658 const tailscale = await isOnTailscale();@@ -678,19 +690,16 @@678690 return { ok: true };679691 }680692 case "ACCEPT_PAIRING": {681- const gate = await requirePro("sync");682- if (!gate.allowed) return { ok: false, error: "pro_required" };693+ // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.683694 return { ok: true };684695 }685696 case "REMOVE_PAIRED_DEVICE": {686697 const { deviceId } = msg.payload as { deviceId: string };687- const { removePairedDevice } = await import('./sync-storage');688698 await removePairedDevice(deviceId);689699 return { ok: true };690700 }691701 case "REMOVE_TRUSTED_NETWORK": {692702 const { ssid, bssid, isTailscale } = msg.payload as any;693- const { removeTrustedNetwork, getTrustedNetworks } = await import('./sync-storage');694703 const networks = await getTrustedNetworks();695704 const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 696705 (isTailscale ? n.isTailscale : n.ssid === ssid) && @@ -702,8 +711,6 @@702711 return { ok: true };703712 }704713 case "MARK_NETWORK_TRUSTED": {705- const { addTrustedNetwork } = await import('./sync-storage');706- const { getCurrentNetwork } = await import('./sync-network');707714 const network = await getCurrentNetwork();708715 await addTrustedNetwork({709716 ssid: network.ssid,@@ -714,14 +721,11 @@714721 return { ok: true };715722 }716723 case "GET_INCOMING_PAIRING_REQUEST": {717- const { getIncomingPairingRequest } = await import('./sync-storage');718724 const request = await getIncomingPairingRequest();719725 return { ok: true, data: request };720726 }721727 case "RESPOND_INCOMING_PAIRING": {722728 const { accept } = msg.payload as { accept: boolean };723- const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =724- await import('./sync-storage');725729 const request = await getIncomingPairingRequest();726730 if (!request) return { ok: false, error: "no_pending_request" };727731 if (Date.now() > request.expiresAt) {@@ -749,17 +753,14 @@749753 return { ok: true };750754 }751755 case "START_PAIRING_SERVER": {752- const gate = await requirePro("sync");753- if (!gate.allowed) return { ok: false, error: "pro_required" };756+ // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.754757 const token = generateSixDigitToken();755758 const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;756- const { setActivePairingToken } = await import('./pairing-server-state');757759 await setActivePairingToken({ token, expiresAt });758760 return { ok: true, data: { token, expiresAt } };759761 }760762 case "STOP_PAIRING_SERVER": {761763 try {762- const { setActivePairingToken } = await import("./pairing-server-state");763764 await setActivePairingToken(null);764765 } catch { /* best-effort */ }765766 return { ok: true };@@ -770,7 +771,6 @@770771 return { ok: false, error: "no_native_host" };771772 }772773 case "GET_RECENT_PAIRING": {773- const { consumeRecentPairing } = await import('./pairing-server-state');774774 const recent = await consumeRecentPairing();775775 return { ok: true, data: recent };776776 }@@ -791,7 +791,6 @@791791 if (!deviceName || !publicKeyHex) {792792 return { ok: false, error: "missing_payload" };793793 }794- const { setIncomingPairingRequest } = await import('./sync-storage');795794 const now = Date.now();796795 await setIncomingPairingRequest({797796 deviceName,@@ -804,7 +803,6 @@804803 case "RESET_SYNC_IDENTITY": {805804 const gate = await requirePro("sync");806805 if (!gate.allowed) return { ok: false, error: "pro_required" };807- const { getPairedDevices, removePairedDevice } = await import('./sync-storage');808806 const devices = await getPairedDevices();809807 for (const device of devices) {810808 await removePairedDevice(device.deviceId);src/crypto/mayan.test.ts+0 −281
@@ -1,281 +0,0 @@1-import { describe, it, expect } from "vitest";2-import {3- MAYAN_MAGIC,4- MAYAN_V2,5- MAX_PAYLOAD_BYTES,6- RUNE_ZONE,7- QUIET_ZONE,8- ANCHOR_FOOTPRINT,9- RS_DATA,10- RS_BLOCK,11- HEADER_BYTES,12- calculateSymbolSize,13- encodeMayanMatrix,14- encodeMayan,15- decodeMatrix,16- decodeMayan,17- isReserved,18-} from "./mayan";19-20-// Synthesize an ImageData from a boolean module matrix at a given module pixel21-// size. Pure black/white, no border padding.22-function matrixToImageData(23- matrix: boolean[][],24- size: number,25- modulePx: number,26-): ImageData {27- const W = size * modulePx;28- const H = size * modulePx;29- const data = new Uint8ClampedArray(W * H * 4);30- for (let r = 0; r < size; r++) {31- for (let c = 0; c < size; c++) {32- const dark = matrix[r][c];33- const v = dark ? 0 : 255;34- for (let dy = 0; dy < modulePx; dy++) {35- for (let dx = 0; dx < modulePx; dx++) {36- const px = c * modulePx + dx;37- const py = r * modulePx + dy;38- const i = (py * W + px) * 4;39- data[i] = v;40- data[i + 1] = v;41- data[i + 2] = v;42- data[i + 3] = 255;43- }44- }45- }46- }47- return { data, width: W, height: H, colorSpace: "srgb" } as ImageData;48-}49-50-// Deterministic PRNG — Mulberry32 — so tests are reproducible.51-function mulberry32(seed: number) {52- let state = seed >>> 0;53- return () => {54- state = (state + 0x6d2b79f5) >>> 0;55- let t = state;56- t = Math.imul(t ^ (t >>> 15), t | 1);57- t ^= t + Math.imul(t ^ (t >>> 7), t | 61);58- return ((t ^ (t >>> 14)) >>> 0) / 0x100000000;59- };60-}61-62-function randomPayload(n: number, seed = 1): Uint8Array {63- const rng = mulberry32(seed);64- const out = new Uint8Array(n);65- for (let i = 0; i < n; i++) out[i] = (rng() * 256) | 0;66- return out;67-}68-69-function flipRandomDataBits(70- matrix: boolean[][],71- size: number,72- fraction: number,73- seed = 1,74-): boolean[][] {75- const dataPositions: { r: number; c: number }[] = [];76- for (let r = QUIET_ZONE; r < size - QUIET_ZONE; r++) {77- for (let c = QUIET_ZONE; c < size - QUIET_ZONE; c++) {78- if (!isReserved(r, c, size)) dataPositions.push({ r, c });79- }80- }81- const rng = mulberry32(seed);82- const out = matrix.map((row) => row.slice());83- const toFlip = Math.floor(dataPositions.length * fraction);84- for (let i = 0; i < toFlip; i++) {85- const j = i + Math.floor(rng() * (dataPositions.length - i));86- [dataPositions[i], dataPositions[j]] = [dataPositions[j], dataPositions[i]];87- const { r, c } = dataPositions[i];88- out[r][c] = !out[r][c];89- }90- return out;91-}92-93-describe("Mayan v2 format", () => {94- it("constants are coherent", () => {95- expect(MAYAN_MAGIC).toBe(0x4d);96- expect(MAYAN_V2).toBe(0x02);97- expect(RUNE_ZONE).toBe(27);98- expect(QUIET_ZONE).toBe(2);99- expect(ANCHOR_FOOTPRINT).toBe(9);100- expect(RS_DATA).toBe(204);101- expect(RS_BLOCK).toBe(255);102- expect(HEADER_BYTES).toBe(6);103- });104-105- it("calculateSymbolSize returns >=64 for tiny payloads", () => {106- expect(calculateSymbolSize(0)).toBeGreaterThanOrEqual(64);107- expect(calculateSymbolSize(50)).toBeGreaterThanOrEqual(64);108- });109-110- it("calculateSymbolSize grows with payload", () => {111- const small = calculateSymbolSize(100);112- const big = calculateSymbolSize(50_000);113- expect(big).toBeGreaterThan(small);114- });115-116- it("rejects payloads larger than MAX_PAYLOAD_BYTES", () => {117- const oversize = new Uint8Array(MAX_PAYLOAD_BYTES + 1);118- expect(() => encodeMayanMatrix(oversize)).toThrow(/payload too large/);119- });120-121- it("isReserved marks anchor footprints as reserved", () => {122- const size = 128;123- // Top-left anchor footprint: rows/cols [QUIET_ZONE..QUIET_ZONE+8]124- expect(isReserved(QUIET_ZONE, QUIET_ZONE, size)).toBe(true);125- expect(isReserved(QUIET_ZONE + 5, QUIET_ZONE + 5, size)).toBe(true);126- expect(isReserved(QUIET_ZONE + 8, QUIET_ZONE + 8, size)).toBe(true);127- // Just outside the anchor footprint should be data area (assuming the128- // timing strip and rune zone don't claim it)129- const safeR = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;130- const safeC = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;131- // The corner (anchorEnd, anchorEnd) lies at the timing-strip intersection,132- // so step one further in133- expect(isReserved(safeR, safeC, size)).toBe(false);134- });135-136- it("isReserved marks size-bit row positions as reserved", () => {137- const size = 128;138- const row = QUIET_ZONE;139- const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;140- for (let i = 0; i < 16; i++) {141- expect(isReserved(row, colStart + i, size)).toBe(true);142- }143- });144-145- it("encodes the symbol size bits round-trippably", () => {146- const payload = randomPayload(50);147- const { matrix, size } = encodeMayanMatrix(payload);148- const row = QUIET_ZONE;149- const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;150- let v = 0;151- for (let i = 0; i < 16; i++) {152- if (matrix[row][colStart + i]) v |= 1 << (15 - i);153- }154- expect(v).toBe(size);155- });156-157- // encodeMayan() requires DOM canvas (only available in browser/jsdom).158- // Paper-size tagging is exercised end-to-end via the Export view.159-});160-161-describe("Mayan v2 round-trip (matrix)", () => {162- it("round-trips a 5-byte payload", () => {163- const payload = new Uint8Array([1, 2, 3, 4, 5]);164- const { matrix, size } = encodeMayanMatrix(payload);165- const decoded = decodeMatrix(matrix, size);166- expect(decoded).not.toBeNull();167- expect(Array.from(decoded!)).toEqual(Array.from(payload));168- });169-170- it("round-trips a 100-byte random payload", () => {171- const payload = randomPayload(100);172- const { matrix, size } = encodeMayanMatrix(payload);173- const decoded = decodeMatrix(matrix, size);174- expect(decoded).not.toBeNull();175- expect(Array.from(decoded!)).toEqual(Array.from(payload));176- });177-178- it("round-trips a 500-byte payload (multi-block RS)", () => {179- const payload = randomPayload(500);180- const { matrix, size } = encodeMayanMatrix(payload);181- const decoded = decodeMatrix(matrix, size);182- expect(decoded).not.toBeNull();183- expect(Array.from(decoded!)).toEqual(Array.from(payload));184- });185-186- it("round-trips a 2000-byte payload", () => {187- const payload = randomPayload(2000);188- const { matrix, size } = encodeMayanMatrix(payload);189- const decoded = decodeMatrix(matrix, size);190- expect(decoded).not.toBeNull();191- expect(Array.from(decoded!)).toEqual(Array.from(payload));192- });193-194- it("round-trips a 20000-byte payload (realistic vault size)", () => {195- const payload = randomPayload(20000);196- const { matrix, size } = encodeMayanMatrix(payload);197- const decoded = decodeMatrix(matrix, size);198- expect(decoded).not.toBeNull();199- expect(Array.from(decoded!)).toEqual(Array.from(payload));200- });201-202- it("rejects a matrix with no Mayan magic byte", () => {203- const fake: boolean[][] = Array.from({ length: 64 }, () =>204- new Array<boolean>(64).fill(false),205- );206- const decoded = decodeMatrix(fake, 64);207- expect(decoded).toBeNull();208- });209-});210-211-describe("Mayan v2 image pipeline", () => {212- it("decodes a 5-byte payload at modulePx=3", () => {213- const payload = new Uint8Array([1, 2, 3, 4, 5]);214- const { matrix, size } = encodeMayanMatrix(payload);215- const imgData = matrixToImageData(matrix, size, 3);216- const decoded = decodeMayan(imgData);217- expect(decoded).not.toBeNull();218- expect(Array.from(decoded!)).toEqual(Array.from(payload));219- });220-221- it("decodes a 100-byte payload at modulePx=3", () => {222- const payload = randomPayload(100);223- const { matrix, size } = encodeMayanMatrix(payload);224- const imgData = matrixToImageData(matrix, size, 3);225- const decoded = decodeMayan(imgData);226- expect(decoded).not.toBeNull();227- expect(Array.from(decoded!)).toEqual(Array.from(payload));228- });229-230- it("decodes a 100-byte payload at modulePx=4", () => {231- const payload = randomPayload(100);232- const { matrix, size } = encodeMayanMatrix(payload);233- const imgData = matrixToImageData(matrix, size, 4);234- const decoded = decodeMayan(imgData);235- expect(decoded).not.toBeNull();236- expect(Array.from(decoded!)).toEqual(Array.from(payload));237- });238-239- it("decodes a 500-byte payload at modulePx=3", () => {240- const payload = randomPayload(500);241- const { matrix, size } = encodeMayanMatrix(payload);242- const imgData = matrixToImageData(matrix, size, 3);243- const decoded = decodeMayan(imgData);244- expect(decoded).not.toBeNull();245- expect(Array.from(decoded!)).toEqual(Array.from(payload));246- });247-248- it("decodes a 5000-byte payload at modulePx=4", () => {249- const payload = randomPayload(5000);250- const { matrix, size } = encodeMayanMatrix(payload);251- const imgData = matrixToImageData(matrix, size, 4);252- const decoded = decodeMayan(imgData);253- expect(decoded).not.toBeNull();254- expect(Array.from(decoded!)).toEqual(Array.from(payload));255- });256-});257-258-describe("Mayan v2 RS recovery", () => {259- it("recovers from 0.5% random bit flips in the data area", () => {260- const payload = randomPayload(50);261- const { matrix, size } = encodeMayanMatrix(payload);262- const corrupted = flipRandomDataBits(matrix, size, 0.005, 42);263- const decoded = decodeMatrix(corrupted, size);264- expect(decoded).not.toBeNull();265- expect(Array.from(decoded!)).toEqual(Array.from(payload));266- });267-268- it("recovers from 1% random bit flips on a multi-block payload", () => {269- // RS(255,204) corrects up to 25 byte errors per block. With interleaving270- // a 1% bit flip rate translates to ~7 byte errors per block — comfortably271- // within capacity. Higher rates approach the per-block limit and are272- // exercised in stress benchmarks separately.273- const payload = randomPayload(500);274- const { matrix, size } = encodeMayanMatrix(payload);275- const corrupted = flipRandomDataBits(matrix, size, 0.01, 7);276- const decoded = decodeMatrix(corrupted, size);277- expect(decoded).not.toBeNull();278- expect(Array.from(decoded!)).toEqual(Array.from(payload));279- });280-});281-src/crypto/mayan.ts+0 −413
@@ -1,413 +0,0 @@1-/**2- * Mayan v2 — Peach's proprietary vault backup matrix code.3- *4- * Format: a square dark/light module grid with three 7×7 corner anchor5- * squares (TL, TR, BL — BR absent for orientation), a 16-bit symbol-size6- * marker adjacent to the TL anchor, two L-shaped timing strips along the7- * top and left active edges, and the Othalan rune ᛟ at the dead center.8- * Vault data radiates outward in a row-by-row grid skipping all reserved9- * positions.10- *11- * Pipeline (caller responsibility): plaintext → zstd compress → AES-256-GCM12- * encrypt → encodeMayan(). Compress-before-encrypt is non-negotiable; the13- * Peach payload format already does this internally so callers passing14- * `buildPayload()` output here are compliant.15- *16- * Header: 6 bytes — magic 0x4D, version 0x02, payload length u32 LE.17- *18- * Reed-Solomon: RS(255,204) — 204 data bytes + 51 parity per block, 20%19- * overhead. Blocks are interleaved (byte j of block i → position20- * i + j*numBlocks) so a localized burst of damage spreads across blocks21- * instead of crushing one.22- *23- * The rune is the soul of the format. The anchors are scaffolding that24- * makes machine decoding reliable — the decoder uses them for homography,25- * not the rune. See peach_mayan_v2_spec.md for the full rationale.26- */27-28-import { rsEncodeBlock } from "./rs";29-30-export const MAYAN_MAGIC = 0x4d;31-export const MAYAN_V2 = 0x02;32-export const RUNE_ZONE = 27;33-export const QUIET_ZONE = 2;34-export const ANCHOR_SIZE = 7;35-export const ANCHOR_FOOTPRINT = 9; // 7×7 anchor + 1-module light ring on every side36-export const RS_DATA = 204;37-export const RS_PARITY = 51;38-export const RS_BLOCK = RS_DATA + RS_PARITY; // 25539-export const HEADER_BYTES = 6;40-export const MAX_SYMBOL_SIZE = 1024;41-export const MIN_SYMBOL_SIZE = 64;42-// Comfortable cap below the 1024-module ceiling. A 1024×1024 symbol holds43-// ~130 KB of raw data bits; after 20% RS overhead + header that leaves44-// ~100 KB of payload room with margin to spare.45-export const MAX_PAYLOAD_BYTES = 100000;46-47-export interface MayanResult {48- canvas: HTMLCanvasElement;49- symbolSize: number;50- payloadBytes: number;51- printable: boolean;52- paperSize: "A4" | "A3" | "A3-required";53-}54-55-// ── SIZE CALCULATION ─────────────────────────────────────────────────────────56-57-export function calculateSymbolSize(payloadBytes: number): number {58- const totalData = payloadBytes + HEADER_BYTES;59- const numBlocks = Math.max(1, Math.ceil(totalData / RS_DATA));60- const dataBits = numBlocks * RS_BLOCK * 8;61-62- for (let size = MIN_SYMBOL_SIZE; size <= MAX_SYMBOL_SIZE; size++) {63- if (usableModules(size) >= dataBits) return size;64- }65- return MAX_SYMBOL_SIZE;66-}67-68-/** Module budget for data placement at a given symbol size. Subtracts every69- * reserved region exactly once. */70-export function usableModules(size: number): number {71- let reserved = 0;72- // Quiet zone: outer 2-module border73- reserved += quietModuleCount(size);74- // Three anchors (9×9 footprint each)75- reserved += 3 * ANCHOR_FOOTPRINT * ANCHOR_FOOTPRINT;76- // Size bits row (16 modules adjacent to TL anchor, on row=QUIET_ZONE)77- reserved += 16;78- // Timing strips (top + left), excluding overlap with rune zone79- reserved += timingModuleCount(size);80- // Rune zone (27×27 centered). Subtract any overlap already counted in81- // timing strips so we don't double-count the rune-zone-vs-timing crossing.82- reserved += runeModuleCount(size) - runeTimingOverlap(size);83- return size * size - reserved;84-}85-86-function quietModuleCount(size: number): number {87- // Four QUIET_ZONE-thick strips minus the corner overlaps88- return 4 * size * QUIET_ZONE - 4 * QUIET_ZONE * QUIET_ZONE;89-}90-91-function timingModuleCount(size: number): number {92- // Top strip: row q+ANCHOR_FOOTPRINT, cols [q+ANCHOR_FOOTPRINT, size-q-ANCHOR_FOOTPRINT)93- // Left strip: col q+ANCHOR_FOOTPRINT, rows [q+ANCHOR_FOOTPRINT, size-q-ANCHOR_FOOTPRINT)94- // Strips don't overlap (one starts at the position the other occupies one off).95- const span = size - 2 * (QUIET_ZONE + ANCHOR_FOOTPRINT);96- return Math.max(0, 2 * span);97-}98-99-function runeModuleCount(_size: number): number {100- return RUNE_ZONE * RUNE_ZONE;101-}102-103-function runeTimingOverlap(size: number): number {104- // The top timing row crosses the rune zone in 27 modules iff the row index105- // q+ANCHOR_FOOTPRINT falls within the rune row range. Same for the left106- // timing column. Compute exactly.107- const center = Math.floor(size / 2);108- const half = Math.floor(RUNE_ZONE / 2);109- const tRow = QUIET_ZONE + ANCHOR_FOOTPRINT;110- const tCol = QUIET_ZONE + ANCHOR_FOOTPRINT;111- const rowOverlaps = tRow >= center - half && tRow <= center + half;112- const colOverlaps = tCol >= center - half && tCol <= center + half;113- return (rowOverlaps ? RUNE_ZONE : 0) + (colOverlaps ? RUNE_ZONE : 0);114-}115-116-// ── ANCHOR / TIMING / SIZE BIT PLACEMENT ─────────────────────────────────────117-118-function drawAnchor(matrix: boolean[][], r0: number, c0: number): void {119- // Clear 9×9 light ring120- for (let dr = 0; dr < ANCHOR_FOOTPRINT; dr++) {121- for (let dc = 0; dc < ANCHOR_FOOTPRINT; dc++) {122- matrix[r0 + dr][c0 + dc] = false;123- }124- }125- // 7×7 anchor pattern at +1, +1 inside the light ring.126- // Outer ring dark, gap light, 3×3 dark center.127- const PAT = [128- [1, 1, 1, 1, 1, 1, 1],129- [1, 0, 0, 0, 0, 0, 1],130- [1, 0, 1, 1, 1, 0, 1],131- [1, 0, 1, 1, 1, 0, 1],132- [1, 0, 1, 1, 1, 0, 1],133- [1, 0, 0, 0, 0, 0, 1],134- [1, 1, 1, 1, 1, 1, 1],135- ];136- for (let dr = 0; dr < ANCHOR_SIZE; dr++) {137- for (let dc = 0; dc < ANCHOR_SIZE; dc++) {138- matrix[r0 + 1 + dr][c0 + 1 + dc] = PAT[dr][dc] === 1;139- }140- }141-}142-143-function placeAnchors(matrix: boolean[][], size: number): void {144- const q = QUIET_ZONE;145- drawAnchor(matrix, q, q);146- drawAnchor(matrix, q, size - q - ANCHOR_FOOTPRINT);147- drawAnchor(matrix, size - q - ANCHOR_FOOTPRINT, q);148- // Bottom-right intentionally absent — orientation marker.149-}150-151-function writeSizeBits(matrix: boolean[][], size: number): void {152- // 16 bits, MSB first, on row=QUIET_ZONE, starting one column to the right153- // of the TL anchor footprint. Lives inside the quiet-zone-adjacent strip154- // but outside the anchor light ring.155- const row = QUIET_ZONE;156- const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;157- for (let i = 0; i < 16; i++) {158- matrix[row][colStart + i] = ((size >> (15 - i)) & 1) === 1;159- }160-}161-162-function sizeBitColumns(): { row: number; colStart: number; colEnd: number } {163- const row = QUIET_ZONE;164- const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;165- return { row, colStart, colEnd: colStart + 16 };166-}167-168-function placeTimingStrips(matrix: boolean[][], size: number): void {169- const q = QUIET_ZONE;170- const start = q + ANCHOR_FOOTPRINT;171- const end = size - q - ANCHOR_FOOTPRINT;172- // Top timing strip — alternating dark/light starting dark on even module173- for (let c = start; c < end; c++) {174- matrix[start][c] = (c & 1) === 0;175- }176- // Left timing strip177- for (let r = start; r < end; r++) {178- matrix[r][start] = (r & 1) === 0;179- }180-}181-182-// ── RUNE DRAWING (unchanged from v1) ─────────────────────────────────────────183-184-function fillRect(185- matrix: boolean[][],186- r: number,187- c: number,188- h: number,189- w: number,190-): void {191- for (let dr = 0; dr < h; dr++) {192- for (let dc = 0; dc < w; dc++) {193- const mr = r + dr;194- const mc = c + dc;195- if (mr >= 0 && mr < matrix.length && mc >= 0 && mc < matrix[0].length) {196- matrix[mr][mc] = true;197- }198- }199- }200-}201-202-function drawDiag(203- matrix: boolean[][],204- r0: number,205- c0: number,206- r1: number,207- c1: number,208- r2: number,209- c2: number,210- stroke: number,211-): void {212- const steps = Math.max(Math.abs(r2 - r1), Math.abs(c2 - c1));213- const half = Math.floor(stroke / 2);214- for (let i = 0; i <= steps; i++) {215- const r = Math.round(r1 + ((r2 - r1) * i) / steps);216- const c = Math.round(c1 + ((c2 - c1) * i) / steps);217- fillRect(matrix, r0 + r - half, c0 + c - half, stroke, stroke);218- }219-}220-221-function drawOthalan(222- matrix: boolean[][],223- centerRow: number,224- centerCol: number,225-): void {226- const r0 = centerRow - 11;227- const c0 = centerCol - 11;228- const STROKE = 3;229- const TOP = [1, 11];230- const RIGHT = [10, 21];231- const BOT = [19, 11];232- const LEFT = [10, 1];233- drawDiag(matrix, r0, c0, TOP[0], TOP[1], RIGHT[0], RIGHT[1], STROKE);234- drawDiag(matrix, r0, c0, RIGHT[0], RIGHT[1], BOT[0], BOT[1], STROKE);235- drawDiag(matrix, r0, c0, BOT[0], BOT[1], LEFT[0], LEFT[1], STROKE);236- drawDiag(matrix, r0, c0, LEFT[0], LEFT[1], TOP[0], TOP[1], STROKE);237- drawDiag(matrix, r0, c0, 19, 8, 22, 4, STROKE);238- drawDiag(matrix, r0, c0, 19, 14, 22, 18, STROKE);239- // Orientation marker: 3×3 dark block at top-right of rune zone240- fillRect(matrix, r0 + 0, c0 + 20, STROKE, STROKE);241-}242-243-// ── RESERVED-POSITION TEST ───────────────────────────────────────────────────244-245-export function isReserved(r: number, c: number, size: number): boolean {246- const q = QUIET_ZONE;247- // Quiet zone248- if (r < q || r >= size - q || c < q || c >= size - q) return true;249-250- // Corner anchors (9×9 footprint)251- const aEnd = q + ANCHOR_FOOTPRINT;252- if (r < aEnd && c < aEnd) return true;253- if (r < aEnd && c >= size - aEnd) return true;254- if (r >= size - aEnd && c < aEnd) return true;255-256- // Size bits (row=q, cols colStart..colEnd-1)257- const sb = sizeBitColumns();258- if (r === sb.row && c >= sb.colStart && c < sb.colEnd) return true;259-260- // Timing strips261- const tStart = aEnd;262- const tEnd = size - aEnd;263- if (r === tStart && c >= tStart && c < tEnd) return true;264- if (c === tStart && r >= tStart && r < tEnd) return true;265-266- // Rune zone (27×27 centered)267- const center = Math.floor(size / 2);268- const half = Math.floor(RUNE_ZONE / 2);269- if (r >= center - half && r <= center + half &&270- c >= center - half && c <= center + half) return true;271-272- return false;273-}274-275-// ── ENCODE PIPELINE ──────────────────────────────────────────────────────────276-277-function buildHeaderAndPayload(encryptedPayload: Uint8Array): Uint8Array {278- const header = new Uint8Array(HEADER_BYTES);279- header[0] = MAYAN_MAGIC;280- header[1] = MAYAN_V2;281- const len = encryptedPayload.length;282- header[2] = len & 0xff;283- header[3] = (len >> 8) & 0xff;284- header[4] = (len >> 16) & 0xff;285- header[5] = (len >> 24) & 0xff;286- const out = new Uint8Array(HEADER_BYTES + encryptedPayload.length);287- out.set(header);288- out.set(encryptedPayload, HEADER_BYTES);289- return out;290-}291-292-/** Reed-Solomon encode + interleave. Splits `data` into RS_DATA-byte blocks293- * (zero-padded last block), encodes each to 255 bytes, then interleaves so294- * byte j of block i lands at position i + j*numBlocks. */295-function rsEncodeAndInterleave(data: Uint8Array): { interleaved: Uint8Array; numBlocks: number } {296- const numBlocks = Math.ceil(data.length / RS_DATA);297- const blocks: Uint8Array[] = [];298- for (let i = 0; i < numBlocks; i++) {299- const slice = data.subarray(i * RS_DATA, (i + 1) * RS_DATA);300- const padded = slice.length === RS_DATA301- ? slice302- : (() => { const p = new Uint8Array(RS_DATA); p.set(slice); return p; })();303- blocks.push(rsEncodeBlock(padded, RS_PARITY));304- }305- const interleaved = new Uint8Array(numBlocks * RS_BLOCK);306- for (let i = 0; i < numBlocks; i++) {307- for (let j = 0; j < RS_BLOCK; j++) {308- interleaved[i + j * numBlocks] = blocks[i][j];309- }310- }311- return { interleaved, numBlocks };312-}313-314-/** Build the 2-D module matrix without rendering to canvas. Used by315- * round-trip tests to skip the binarization step. */316-export function encodeMayanMatrix(317- encryptedPayload: Uint8Array,318-): { matrix: boolean[][]; size: number } {319- if (encryptedPayload.length > MAX_PAYLOAD_BYTES) {320- throw new Error(321- `Mayan payload too large: ${encryptedPayload.length} bytes (max ${MAX_PAYLOAD_BYTES})`,322- );323- }324- const size = calculateSymbolSize(encryptedPayload.length);325- if (size > MAX_SYMBOL_SIZE) {326- throw new Error(`Mayan: symbol size ${size} exceeds max ${MAX_SYMBOL_SIZE}`);327- }328- const center = Math.floor(size / 2);329- const fullData = buildHeaderAndPayload(encryptedPayload);330- const { interleaved } = rsEncodeAndInterleave(fullData);331-332- // Bits, MSB-first per byte333- const bits: boolean[] = new Array(interleaved.length * 8);334- for (let bi = 0; bi < interleaved.length; bi++) {335- const byte = interleaved[bi];336- for (let i = 0; i < 8; i++) {337- bits[bi * 8 + i] = ((byte >> (7 - i)) & 1) === 1;338- }339- }340-341- // Build matrix342- const matrix: boolean[][] = Array.from(343- { length: size },344- () => new Array<boolean>(size).fill(false),345- );346-347- // Structural elements first; rune drawn last so its pixels win the348- // crossing region with the timing strips.349- placeAnchors(matrix, size);350- writeSizeBits(matrix, size);351- placeTimingStrips(matrix, size);352- drawOthalan(matrix, center, center);353-354- // Place data bits in row-major scan, skipping reserved positions355- let bitIdx = 0;356- for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bitIdx < bits.length; r++) {357- for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bitIdx < bits.length; c++) {358- if (isReserved(r, c, size)) continue;359- matrix[r][c] = bits[bitIdx++];360- }361- }362-363- return { matrix, size };364-}365-366-export function encodeMayan(367- encryptedPayload: Uint8Array,368- modulePx = 3,369-): MayanResult {370- const { matrix, size } = encodeMayanMatrix(encryptedPayload);371- const canvas = renderMatrix(matrix, size, modulePx);372- const paperSize: MayanResult["paperSize"] =373- size <= 350 ? "A4" :374- size <= 500 ? "A3" :375- "A3-required";376- return {377- canvas,378- symbolSize: size,379- payloadBytes: encryptedPayload.length,380- printable: paperSize === "A4",381- paperSize,382- };383-}384-385-function renderMatrix(386- matrix: boolean[][],387- size: number,388- modulePx: number,389-): HTMLCanvasElement {390- const canvas = document.createElement("canvas");391- canvas.width = size * modulePx;392- canvas.height = size * modulePx;393- const ctx = canvas.getContext("2d");394- if (!ctx) throw new Error("Mayan: 2D canvas unavailable");395- ctx.fillStyle = "#ffffff";396- ctx.fillRect(0, 0, canvas.width, canvas.height);397- ctx.fillStyle = "#000000";398- for (let r = 0; r < size; r++) {399- for (let c = 0; c < size; c++) {400- if (matrix[r][c]) {401- ctx.fillRect(c * modulePx, r * modulePx, modulePx, modulePx);402- }403- }404- }405- return canvas;406-}407-408-// ── DECODE RE-EXPORTS ────────────────────────────────────────────────────────409-// The decoder lives in mayan_decode.ts so the encoder can be imported without410-// pulling in the homography / image-processing code. Callers that want the411-// historical `import { decodeMayan } from "./mayan"` ergonomics get it here.412-413-export { decodeMayan, decodeMatrix } from "./mayan_decode";src/crypto/mayan_decode.ts+0 −659
@@ -1,659 +0,0 @@1-/**2- * Mayan v2 decoder — anchor-finder + homography pipeline.3- *4- * Pipeline:5- * binarize → find 3 corner anchors via 1:1:3:1:1 run-length pattern →6- * classify TL/TR/BL by quadrant → compute DLT homography (pixel↔module)7- * from 4 control points (TL, TR, BL, extrapolated BR) → sample grid →8- * read 16-bit size marker → re-sample at exact size if different →9- * probe `numBlocks` from the interleaved header → RS-decode each block →10- * strip header, return encrypted payload.11- */12-13-import { rsDecodeBlock } from "./rs";14-import {15- MAYAN_MAGIC,16- MAYAN_V2,17- RS_DATA,18- RS_PARITY,19- RS_BLOCK,20- HEADER_BYTES,21- QUIET_ZONE,22- ANCHOR_FOOTPRINT,23- MIN_SYMBOL_SIZE,24- MAX_SYMBOL_SIZE,25- isReserved,26-} from "./mayan";27-28-// ── PUBLIC ENTRY POINTS ──────────────────────────────────────────────────────29-30-/** Decode a Mayan code (v1 or v2) from raw image data. Returns the31- * encrypted-and-compressed payload byte stream — caller is responsible32- * for AES-GCM decrypt and zstd decompress (or, in Peach, parsePayload).33- *34- * Returns null when no Mayan code is found, magic byte mismatches, or35- * Reed-Solomon correction fails. */36-export function decodeMayan(imageData: ImageData): Uint8Array | null {37- const binary = adaptiveBinarize(imageData);38- const W = imageData.width;39- const H = imageData.height;40-41- const anchors = findAnchors(binary, W, H);42- if (anchors.length < 3) return null;43-44- const orient = classifyAnchors(anchors);45- if (!orient) return null;46- const { TL, TR, BL } = orient;47- const modulePx = orient.modulePx;48- if (modulePx < 1) return null;49-50- // Estimate symbol size from anchor spacing. Anchor inner-3×3 centers are51- // at module (q+4, q+4) for TL — so the distance TL→TR in modules is52- // (size - 2q - 9) and we recover size = round(distance/modulePx) + 2q + 9.53- const distH = Math.hypot(TR.x - TL.x, TR.y - TL.y);54- const distV = Math.hypot(BL.x - TL.x, BL.y - TL.y);55- const span = (distH + distV) / 2 / modulePx;56- let estSize = Math.round(span) + 2 * QUIET_ZONE + ANCHOR_FOOTPRINT;57- estSize = clamp(estSize, MIN_SYMBOL_SIZE, MAX_SYMBOL_SIZE);58-59- const matrix = sampleAndReadExact(binary, TL, TR, BL, estSize);60- if (!matrix) return null;61-62- return decodeMatrix(matrix.matrix, matrix.size);63-}64-65-/** Decode a Mayan v2 module matrix directly. Used by tests that bypass66- * binarization and homography. */67-export function decodeMatrix(68- matrix: boolean[][],69- symbolSize: number,70-): Uint8Array | null {71- // Read every available data bit. The number of blocks is unknown until72- // we parse the header, but the header is itself interleaved across73- // blocks — so we read everything, probe `numBlocks` against the header,74- // then RS-decode at the right blocking.75- const allBits = readDataBits(matrix, symbolSize, Infinity);76- const interleaved = new Uint8Array(bitsToBytes(allBits));77-78- if (interleaved.length === 0) return null;79- const maxK = Math.floor(interleaved.length / RS_BLOCK);80- // Magic byte sits at interleaved[0] regardless of numBlocks (block 0,81- // byte 0 → position 0). Skip the fast probe if it's already wrong; the82- // slow probe still gets a chance to RS-correct a flipped magic byte.83- // Fast probe first: for each candidate k, the header bytes live at84- // positions [0, k, 2k, 3k, 4k, 5k]. Check version + length consistency.85- for (let k = 1; interleaved[0] === MAYAN_MAGIC && k <= maxK; k++) {86- if (5 * k >= interleaved.length) break;87- const version = interleaved[k];88- if (version !== MAYAN_V2) continue;89- const length = interleaved[2 * k]90- | (interleaved[3 * k] << 8)91- | (interleaved[4 * k] << 16)92- | (interleaved[5 * k] << 24);93- if (length < 0 || length > 100_000_000) continue;94- if (Math.ceil((length + HEADER_BYTES) / RS_DATA) !== k) continue;95- const decoded = rsDecodeAtBlocks(interleaved, k);96- if (!decoded) continue;97- // Re-validate header from the corrected stream (in case the fast98- // probe accepted on un-corrected bytes that happened to be valid).99- if (decoded[0] !== MAYAN_MAGIC || decoded[1] !== MAYAN_V2) continue;100- const len2 = decoded[2] | (decoded[3] << 8) | (decoded[4] << 16) | (decoded[5] << 24);101- if (len2 < 0 || len2 + HEADER_BYTES > decoded.length) continue;102- return decoded.slice(HEADER_BYTES, HEADER_BYTES + len2);103- }104-105- // Slow probe: header may be corrupted. RS-decode block 0 at every106- // candidate k and check the corrected header.107- for (let k = 1; k <= maxK; k++) {108- const decoded = rsDecodeAtBlocks(interleaved, k);109- if (!decoded) continue;110- if (decoded[0] !== MAYAN_MAGIC) continue;111- if (decoded[1] !== MAYAN_V2) continue;112- const len = decoded[2] | (decoded[3] << 8) | (decoded[4] << 16) | (decoded[5] << 24);113- if (len < 0 || len + HEADER_BYTES > decoded.length) continue;114- if (Math.ceil((len + HEADER_BYTES) / RS_DATA) !== k) continue;115- return decoded.slice(HEADER_BYTES, HEADER_BYTES + len);116- }117-118- return null;119-}120-121-/** Deinterleave the first `numBlocks * 255` bytes and RS-decode each block.122- * Returns the concatenated `numBlocks * 204`-byte data payload (with123- * header still prepended), or null if any block is uncorrectable. */124-function rsDecodeAtBlocks(125- interleaved: Uint8Array,126- numBlocks: number,127-): Uint8Array | null {128- if (interleaved.length < numBlocks * RS_BLOCK) return null;129- const out = new Uint8Array(numBlocks * RS_DATA);130- const block = new Uint8Array(RS_BLOCK);131- for (let i = 0; i < numBlocks; i++) {132- for (let j = 0; j < RS_BLOCK; j++) {133- block[j] = interleaved[i + j * numBlocks];134- }135- const corrected = rsDecodeBlock(block, RS_PARITY);136- if (!corrected) return null;137- out.set(corrected, i * RS_DATA);138- }139- return out;140-}141-142-// ── BINARIZATION ─────────────────────────────────────────────────────────────143-144-function adaptiveBinarize(imageData: ImageData): boolean[][] {145- const { data, width, height } = imageData;146- const out: boolean[][] = Array.from(147- { length: height },148- () => new Array<boolean>(width).fill(false),149- );150- const R = 9;151- const integral = new Float64Array((width + 1) * (height + 1));152- for (let y = 0; y < height; y++) {153- let rowSum = 0;154- for (let x = 0; x < width; x++) {155- const i = (y * width + x) * 4;156- const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];157- rowSum += gray;158- integral[(y + 1) * (width + 1) + (x + 1)] =159- integral[y * (width + 1) + (x + 1)] + rowSum;160- }161- }162- for (let y = 0; y < height; y++) {163- for (let x = 0; x < width; x++) {164- const i = (y * width + x) * 4;165- const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2];166- const x0 = Math.max(0, x - R);167- const y0 = Math.max(0, y - R);168- const x1 = Math.min(width - 1, x + R);169- const y1 = Math.min(height - 1, y + R);170- const sum =171- integral[(y1 + 1) * (width + 1) + (x1 + 1)] -172- integral[(y0) * (width + 1) + (x1 + 1)] -173- integral[(y1 + 1) * (width + 1) + (x0)] +174- integral[(y0) * (width + 1) + (x0)];175- const count = (x1 - x0 + 1) * (y1 - y0 + 1);176- out[y][x] = gray < sum / count - 8;177- }178- }179- return out;180-}181-182-// ── ANCHOR DETECTION ─────────────────────────────────────────────────────────183-184-interface AnchorMatch {185- cx: number;186- cy: number;187- modulePx: number;188-}189-190-interface RawCandidate {191- cx: number;192- cy: number;193- modulePx: number;194-}195-196-/** Scan rows for the 1:1:3:1:1 light-dark-light-dark-light pattern that197- * characterises the corner anchor (outer dark frame, light gap, inner198- * 3-module dark block, light gap, outer dark frame). Confirm vertically199- * at the same column, then verify the candidate's center 3×3-module block200- * is solidly dark. Returns clustered, deduplicated anchor centers. */201-function findAnchors(binary: boolean[][], W: number, H: number): AnchorMatch[] {202- const candidates: RawCandidate[] = [];203-204- for (let y = 0; y < H; y += 2) {205- scanLineForAnchor(206- (i) => binary[y]?.[i] ?? false,207- W,208- (cxRel, modulePx) => candidates.push({ cx: cxRel, cy: y, modulePx }),209- );210- }211-212- const confirmed: AnchorMatch[] = [];213- for (const c of candidates) {214- if (c.cx < 0 || c.cx >= W) continue;215- const hits: { cy: number; modulePx: number }[] = [];216- scanLineForAnchor(217- (i) => binary[i]?.[c.cx] ?? false,218- H,219- (cyRel, modulePx) => hits.push({ cy: cyRel, modulePx }),220- );221- let best: { cy: number; modulePx: number } | null = null;222- for (const h of hits) {223- if (Math.abs(h.cy - c.cy) > c.modulePx * 5) continue;224- if (!best || Math.abs(h.cy - c.cy) < Math.abs(best.cy - c.cy)) best = h;225- }226- if (!best) continue;227- const cy = Math.round((c.cy + best.cy) / 2);228- const modulePx = (c.modulePx + best.modulePx) / 2;229- if (!verifyAnchorCenter(binary, c.cx, cy, modulePx, W, H)) continue;230- confirmed.push({ cx: c.cx, cy, modulePx });231- }232-233- return clusterAnchors(confirmed);234-}235-236-/** Verify that the candidate's central 3×3-module block is solidly dark237- * (≥80% of sampled pixels). The horizontal and vertical 1:1:3:1:1 scans238- * alone match many false positives in random data — but the real anchor's239- * central 3×3 must be a uniform dark block. This kills the false positives240- * cheaply. */241-function verifyAnchorCenter(242- binary: boolean[][],243- cx: number, cy: number,244- modulePx: number,245- W: number, H: number,246-): boolean {247- // Sample a 3×3 grid of points spanning the central 3 modules248- const step = modulePx;249- let dark = 0, total = 0;250- for (let dr = -1; dr <= 1; dr++) {251- for (let dc = -1; dc <= 1; dc++) {252- const x = Math.round(cx + dc * step);253- const y = Math.round(cy + dr * step);254- if (x < 0 || x >= W || y < 0 || y >= H) return false;255- total++;256- if (binary[y][x]) dark++;257- }258- }259- if (dark < total * 0.8) return false;260-261- // And verify the immediate ring outside (at ±2 modules) has at least262- // some light points — the real anchor has a light gap there.263- let light = 0, ringTotal = 0;264- for (let dr = -2; dr <= 2; dr++) {265- for (let dc = -2; dc <= 2; dc++) {266- if (Math.abs(dr) < 2 && Math.abs(dc) < 2) continue; // only the ring267- const x = Math.round(cx + dc * step);268- const y = Math.round(cy + dr * step);269- if (x < 0 || x >= W || y < 0 || y >= H) continue;270- ringTotal++;271- if (!binary[y][x]) light++;272- }273- }274- return ringTotal > 0 && light >= ringTotal * 0.5;275-}276-277-/** Scan a single 1-D line via a `pixel(i)` accessor for the 1:1:3:1:1278- * pattern. Calls `onMatch(centerPosition, modulePx)` for every match. */279-function scanLineForAnchor(280- pixel: (i: number) => boolean,281- N: number,282- onMatch: (center: number, modulePx: number) => void,283-): void {284- // Sliding-window run-length scan: maintain a rolling history of the last285- // 5 alternating runs (light, dark, light, dark, light). On each new run,286- // evaluate the trailing 5-run window for the 1:1:3:1:1 ratio.287- const runs: { len: number; dark: boolean; start: number }[] = [];288- let i = 0;289- let cur = pixel(0);290- let runStart = 0;291- while (i <= N) {292- const px = i < N ? pixel(i) : !cur;293- if (px !== cur || i === N) {294- runs.push({ len: i - runStart, dark: cur, start: runStart });295- runStart = i;296- cur = px;297- // Keep only last 5298- if (runs.length > 5) runs.shift();299- if (runs.length === 5) {300- const [r0, r1, r2, r3, r4] = runs;301- // We need light, dark, light, dark, light302- if (!r0.dark && r1.dark && !r2.dark && r3.dark && !r4.dark) {303- const total = r0.len + r1.len + r2.len + r3.len + r4.len;304- const unit = total / 7;305- if (unit >= 1 &&306- within(r0.len, unit, 0.5) &&307- within(r1.len, unit, 0.5) &&308- within(r2.len, unit * 3, 0.8) &&309- within(r3.len, unit, 0.5) &&310- within(r4.len, unit, 0.5)) {311- const center = r2.start + r2.len / 2;312- onMatch(Math.round(center), unit);313- }314- }315- // Also accept the case where the line starts inside a dark region:316- // dark, light, dark, light, dark — with the middle dark being the317- // 3-unit center block.318- if (r0.dark && !r1.dark && r2.dark && !r3.dark && r4.dark) {319- const total = r0.len + r1.len + r2.len + r3.len + r4.len;320- const unit = total / 7;321- if (unit >= 1 &&322- within(r0.len, unit, 0.5) &&323- within(r1.len, unit, 0.5) &&324- within(r2.len, unit * 3, 0.8) &&325- within(r3.len, unit, 0.5) &&326- within(r4.len, unit, 0.5)) {327- const center = r2.start + r2.len / 2;328- onMatch(Math.round(center), unit);329- }330- }331- }332- }333- i++;334- }335-}336-337-function within(value: number, target: number, tolerance: number): boolean {338- return Math.abs(value - target) <= target * tolerance;339-}340-341-/** Cluster nearby anchor matches into single representatives. Two matches342- * are the same anchor if they are within ~2 modules of each other. */343-function clusterAnchors(matches: AnchorMatch[]): AnchorMatch[] {344- const clusters: AnchorMatch[][] = [];345- for (const m of matches) {346- let placed = false;347- for (const cluster of clusters) {348- const ref = cluster[0];349- const distSq = (m.cx - ref.cx) ** 2 + (m.cy - ref.cy) ** 2;350- if (distSq <= (ref.modulePx * 3) ** 2) {351- cluster.push(m);352- placed = true;353- break;354- }355- }356- if (!placed) clusters.push([m]);357- }358- return clusters.map((cluster) => {359- let sx = 0, sy = 0, sm = 0;360- for (const c of cluster) { sx += c.cx; sy += c.cy; sm += c.modulePx; }361- return {362- cx: sx / cluster.length,363- cy: sy / cluster.length,364- modulePx: sm / cluster.length,365- };366- });367-}368-369-// ── ORIENTATION CLASSIFICATION ───────────────────────────────────────────────370-371-interface Orientation {372- TL: Point;373- TR: Point;374- BL: Point;375- modulePx: number;376-}377-378-interface Point { x: number; y: number }379-380-/** Pick the three anchors corresponding to the genuine corner squares.381- *382- * Strategy: the real anchors share the same modulePx (they're the same383- * physical pattern photographed in the same image). False positives — the384- * 1:1:3:1:1 run-length artefacts that random data sometimes produces —385- * scatter across modulePx values. Cluster candidates by modulePx, keep386- * the dominant bucket, then among that bucket pick the triplet that best387- * matches a right-angle isoceles triangle with equal legs (TL→TR ≈ TL→BL,388- * angle at TL ≈ 90°). */389-function classifyAnchors(anchors: AnchorMatch[]): Orientation | null {390- if (anchors.length < 3) return null;391-392- let best: {393- TL: AnchorMatch; TR: AnchorMatch; BL: AnchorMatch; score: number;394- } | null = null;395-396- const N = anchors.length;397- for (let i = 0; i < N; i++) {398- for (let j = 0; j < N; j++) {399- if (j === i) continue;400- for (let k = 0; k < N; k++) {401- if (k === i || k === j) continue;402- const tl = anchors[i];403- const a = anchors[j];404- const b = anchors[k];405- const va = { x: a.cx - tl.cx, y: a.cy - tl.cy };406- const vb = { x: b.cx - tl.cx, y: b.cy - tl.cy };407- const lenA = Math.hypot(va.x, va.y);408- const lenB = Math.hypot(vb.x, vb.y);409- if (lenA < 1 || lenB < 1) continue;410- // Order: TR has positive cross product (rightward then downward in411- // y-down image coords).412- const cross = va.x * vb.y - va.y * vb.x;413- if (cross <= 0) continue;414- // Right-angle at TL415- const cos = (va.x * vb.x + va.y * vb.y) / (lenA * lenB);416- const angularScore = -Math.abs(cos);417- // Equal legs418- const legRatio = Math.min(lenA, lenB) / Math.max(lenA, lenB);419- const lengthScore = -Math.abs(1 - legRatio);420- // Module size consistency — real anchors share one modulePx.421- const meanM = (tl.modulePx + a.modulePx + b.modulePx) / 3;422- const varM = (423- (tl.modulePx - meanM) ** 2 +424- (a.modulePx - meanM) ** 2 +425- (b.modulePx - meanM) ** 2426- ) / 3;427- const stdM = Math.sqrt(varM);428- const moduleScore = -stdM / Math.max(meanM, 1);429- // Triangle leg ≈ symbol pixel span. Real anchors are far apart.430- const sizeScore = Math.log(lenA + lenB) * 0.1;431- const score =432- angularScore * 5 +433- lengthScore * 5 +434- moduleScore * 8 +435- sizeScore;436- if (!best || score > best.score) {437- best = { TL: tl, TR: a, BL: b, score };438- }439- }440- }441- }442- if (!best) return null;443- const modulePx = (best.TL.modulePx + best.TR.modulePx + best.BL.modulePx) / 3;444- return {445- TL: { x: best.TL.cx, y: best.TL.cy },446- TR: { x: best.TR.cx, y: best.TR.cy },447- BL: { x: best.BL.cx, y: best.BL.cy },448- modulePx,449- };450-}451-452-// ── HOMOGRAPHY (DLT, 4 point pairs) ──────────────────────────────────────────453-454-/** Solve a 3×3 homography H mapping src points (sx, sy) to dst points455- * (dx, dy) such that `dst = H * src` in homogeneous coords. Uses DLT with456- * h22 fixed at 1 — sufficient for non-degenerate quadrilaterals.457- * Returns the flat 9-element matrix [h00..h22] or null on singular input. */458-function computeHomography(src: Point[], dst: Point[]): number[] | null {459- if (src.length !== 4 || dst.length !== 4) return null;460- // 8 unknowns: h00,h01,h02,h10,h11,h12,h20,h21461- // Per pair: dx = h00*sx + h01*sy + h02 - h20*sx*dx - h21*sy*dx462- // dy = h10*sx + h11*sy + h12 - h20*sx*dy - h21*sy*dy463- const A: number[][] = [];464- const b: number[] = [];465- for (let i = 0; i < 4; i++) {466- const sx = src[i].x, sy = src[i].y;467- const dx = dst[i].x, dy = dst[i].y;468- A.push([sx, sy, 1, 0, 0, 0, -sx * dx, -sy * dx]);469- b.push(dx);470- A.push([0, 0, 0, sx, sy, 1, -sx * dy, -sy * dy]);471- b.push(dy);472- }473- const x = solve8(A, b);474- if (!x) return null;475- return [x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], 1];476-}477-478-/** Solve an 8×8 linear system via partial-pivot Gaussian elimination. */479-function solve8(A: number[][], b: number[]): number[] | null {480- const n = 8;481- const M: number[][] = A.map((row, i) => [...row, b[i]]);482- for (let col = 0; col < n; col++) {483- let pivot = col;484- for (let r = col + 1; r < n; r++) {485- if (Math.abs(M[r][col]) > Math.abs(M[pivot][col])) pivot = r;486- }487- if (Math.abs(M[pivot][col]) < 1e-12) return null;488- [M[col], M[pivot]] = [M[pivot], M[col]];489- for (let r = 0; r < n; r++) {490- if (r === col) continue;491- const factor = M[r][col] / M[col][col];492- if (factor === 0) continue;493- for (let c = col; c <= n; c++) M[r][c] -= factor * M[col][c];494- }495- }496- const x: number[] = new Array(n);497- for (let i = 0; i < n; i++) x[i] = M[i][n] / M[i][i];498- return x;499-}500-501-function applyHomography(H: number[], px: number, py: number): Point {502- const w = H[6] * px + H[7] * py + H[8];503- return {504- x: (H[0] * px + H[1] * py + H[2]) / w,505- y: (H[3] * px + H[4] * py + H[5]) / w,506- };507-}508-509-function invertHomography(H: number[]): number[] | null {510- const a = H[0], b = H[1], c = H[2];511- const d = H[3], e = H[4], f = H[5];512- const g = H[6], h = H[7], i = H[8];513- const det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);514- if (Math.abs(det) < 1e-12) return null;515- const inv = 1 / det;516- return [517- (e * i - f * h) * inv,518- (c * h - b * i) * inv,519- (b * f - c * e) * inv,520- (f * g - d * i) * inv,521- (a * i - c * g) * inv,522- (c * d - a * f) * inv,523- (d * h - e * g) * inv,524- (b * g - a * h) * inv,525- (a * e - b * d) * inv,526- ];527-}528-529-// ── GRID SAMPLING ────────────────────────────────────────────────────────────530-531-/** Anchor inner-3×3-block module coordinates for a given symbol size.532- * These are the *center* coordinates (module index + 0.5) used as the533- * destination side of the homography. */534-function anchorModuleCenter(size: number, corner: "TL" | "TR" | "BL" | "BR"): Point {535- const q = QUIET_ZONE;536- // Inner 3×3 dark block centers at module (q+4, q+4) for TL etc. Add 0.5537- // for module-center coordinates.538- const tlx = q + 4 + 0.5;539- const tly = q + 4 + 0.5;540- const farx = size - q - 5 + 0.5; // size - q - ANCHOR_FOOTPRINT + 4 + 0.5541- const fary = size - q - 5 + 0.5;542- switch (corner) {543- case "TL": return { x: tlx, y: tly };544- case "TR": return { x: farx, y: tly };545- case "BL": return { x: tlx, y: fary };546- case "BR": return { x: farx, y: fary };547- }548-}549-550-/** Sample the binary image at the ideal module grid for a given symbol551- * size, using a homography fitted from the three known anchors plus an552- * extrapolated BR. */553-function sampleGrid(554- binary: boolean[][],555- TL: Point, TR: Point, BL: Point,556- size: number,557-): boolean[][] | null {558- // BR ≈ TL + (TR - TL) + (BL - TL)559- const BR: Point = {560- x: TL.x + (TR.x - TL.x) + (BL.x - TL.x),561- y: TL.y + (TR.y - TL.y) + (BL.y - TL.y),562- };563- // src = pixel coords of anchors; dst = module-center coords.564- const src = [TL, TR, BL, BR];565- const dst = [566- anchorModuleCenter(size, "TL"),567- anchorModuleCenter(size, "TR"),568- anchorModuleCenter(size, "BL"),569- anchorModuleCenter(size, "BR"),570- ];571- // We need module → pixel sampling; build H_inv directly by swapping src/dst.572- const Hinv = computeHomography(dst, src);573- if (!Hinv) return null;574-575- const matrix: boolean[][] = Array.from(576- { length: size },577- () => new Array<boolean>(size).fill(false),578- );579- const H = binary.length;580- const W = binary[0]?.length ?? 0;581- for (let r = 0; r < size; r++) {582- for (let c = 0; c < size; c++) {583- const p = applyHomography(Hinv, c + 0.5, r + 0.5);584- const ix = Math.round(p.x);585- const iy = Math.round(p.y);586- if (iy >= 0 && iy < H && ix >= 0 && ix < W) {587- matrix[r][c] = binary[iy][ix];588- }589- }590- }591- return matrix;592-}593-594-/** Two-pass sampling: read the size bits at the estimated size, then595- * re-sample at the exact size if they differ. */596-function sampleAndReadExact(597- binary: boolean[][],598- TL: Point, TR: Point, BL: Point,599- estSize: number,600-): { matrix: boolean[][]; size: number } | null {601- const rough = sampleGrid(binary, TL, TR, BL, estSize);602- if (!rough) return null;603- const exact = readSizeBits(rough);604- if (exact >= MIN_SYMBOL_SIZE && exact <= MAX_SYMBOL_SIZE && exact !== estSize) {605- const final = sampleGrid(binary, TL, TR, BL, exact);606- if (!final) return null;607- return { matrix: final, size: exact };608- }609- if (exact < MIN_SYMBOL_SIZE || exact > MAX_SYMBOL_SIZE) {610- // Fall back to the estimated size — RS may still recover.611- return { matrix: rough, size: estSize };612- }613- return { matrix: rough, size: exact };614-}615-616-function readSizeBits(matrix: boolean[][]): number {617- const row = QUIET_ZONE;618- const colStart = QUIET_ZONE + ANCHOR_FOOTPRINT + 1;619- let val = 0;620- for (let i = 0; i < 16; i++) {621- if (matrix[row]?.[colStart + i]) val |= 1 << (15 - i);622- }623- return val;624-}625-626-// ── BIT / BYTE HELPERS ───────────────────────────────────────────────────────627-628-function readDataBits(629- matrix: boolean[][],630- size: number,631- count: number,632-): boolean[] {633- const bits: boolean[] = [];634- for (let r = QUIET_ZONE; r < size - QUIET_ZONE && bits.length < count; r++) {635- const row = matrix[r];636- if (!row) continue;637- for (let c = QUIET_ZONE; c < size - QUIET_ZONE && bits.length < count; c++) {638- if (isReserved(r, c, size)) continue;639- bits.push(row[c] ?? false);640- }641- }642- return bits;643-}644-645-function bitsToBytes(bits: boolean[]): number[] {646- const bytes: number[] = [];647- for (let i = 0; i < bits.length; i += 8) {648- let byte = 0;649- for (let j = 0; j < 8 && i + j < bits.length; j++) {650- if (bits[i + j]) byte |= 1 << (7 - j);651- }652- bytes.push(byte);653- }654- return bytes;655-}656-657-function clamp(v: number, lo: number, hi: number): number {658- return Math.max(lo, Math.min(hi, v));659-}src/crypto/peachQR.ts+0 −73
@@ -1,73 +0,0 @@1-/**2- * peachQR.ts — render a Peach binary payload as a printable QR code.3- *4- * The spec (§4) calls for a proprietary dense matrix format with custom5- * anchor patterns and Reed-Solomon ECC tuned for high-capacity payloads.6- * That decoder is non-trivial (perspective correction + ECC + camera7- * frame analysis) and is genuinely out of scope for v2.5.0. For now the8- * print-backup path uses a standard QR code via the bundled `qrcode`9- * package with byte-mode segments and low ECC for maximum capacity.10- *11- * Standard QR (v40, ECC level L) caps at 2,953 binary bytes — that12- * covers the common case (vaults up to roughly 100 entries). Larger13- * vaults are advised to use PeachScript instead. The full proprietary14- * matrix code (20 KB capacity, custom Reed-Solomon, four-anchor15- * orientation) is a follow-up; the call sites below already separate16- * "encode" from "render", so swapping in the spec-compliant codec later17- * does not require touching the UI layer.18- */19-20-import QRCode from "qrcode";21-22-/** Approximate binary capacity for standard QR Version 40, ECC level L. */23-export const QR_MAX_BYTES = 2953;24-25-export interface PeachQROptions {26- /** Pixel width of one matrix module. Higher = larger PNG. Default 6. */27- scale?: number;28- /** White margin in modules around the code. Default 4. */29- margin?: number;30-}31-32-/**33- * Render a binary payload to a black-and-white QR canvas. Throws34- * `QR_PAYLOAD_TOO_LARGE` if the payload exceeds standard QR capacity —35- * callers should fall back to PeachScript in that case.36- */37-export async function renderPeachQR(38- payload: Uint8Array,39- opts: PeachQROptions = {}40-): Promise<HTMLCanvasElement> {41- if (payload.length > QR_MAX_BYTES) {42- throw new Error("QR_PAYLOAD_TOO_LARGE");43- }44-45- const canvas = document.createElement("canvas");46- await QRCode.toCanvas(47- canvas,48- [{ data: payload, mode: "byte" }],49- {50- errorCorrectionLevel: "L",51- margin: opts.margin ?? 4,52- scale: opts.scale ?? 6,53- color: { dark: "#000000", light: "#ffffff" },54- }55- );56- return canvas;57-}58-59-/** Convenience: PNG data URL ready for download. */60-export async function renderPeachQRDataURL(61- payload: Uint8Array,62- opts: PeachQROptions = {}63-): Promise<string> {64- if (payload.length > QR_MAX_BYTES) {65- throw new Error("QR_PAYLOAD_TOO_LARGE");66- }67- return QRCode.toDataURL([{ data: payload, mode: "byte" }], {68- errorCorrectionLevel: "L",69- margin: opts.margin ?? 4,70- scale: opts.scale ?? 6,71- color: { dark: "#000000", light: "#ffffff" },72- });73-}src/crypto/rs.test.ts+0 −84
@@ -1,84 +0,0 @@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+0 −340
@@ -1,340 +0,0 @@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- * Two API surfaces:6- *7- * 1. Per-block primitives (Mayan v2): rsEncodeBlock / rsDecodeBlock with8- * arbitrary parity count. Used for RS(255,204) — 204 data + 51 parity —9- * where the Mayan layer does its own blocking and interleaving.10- *11- * 2. Legacy block-mode RS(255,178): reedSolomonEncode / reedSolomonDecode12- * auto-block a payload into 178/77 blocks. Used by Mayan v1 codes only.13- * New v2 codes do not go through this path.14- */15-16-const FIELD_SIZE = 256;17-const PRIMITIVE_POLY = 0x11d;18-19-const BLOCK_DATA = 178;20-const BLOCK_PARITY = 77;21-const BLOCK_TOTAL = BLOCK_DATA + BLOCK_PARITY; // 25522-23-// ── GF(2^8) tables ──────────────────────────────────────────────────────────24-25-const EXP_TABLE = new Uint8Array(FIELD_SIZE * 2);26-const LOG_TABLE = new Uint8Array(FIELD_SIZE);27-28-(function initTables() {29- let x = 1;30- for (let i = 0; i < 255; i++) {31- EXP_TABLE[i] = x;32- LOG_TABLE[x] = i;33- x <<= 1;34- if (x & 0x100) x ^= PRIMITIVE_POLY;35- }36- // Double-length exp table avoids modulo on the hot path.37- for (let i = 255; i < 512; i++) EXP_TABLE[i] = EXP_TABLE[i - 255];38-})();39-40-function gfMul(a: number, b: number): number {41- if (a === 0 || b === 0) return 0;42- return EXP_TABLE[LOG_TABLE[a] + LOG_TABLE[b]];43-}44-45-function gfDiv(a: number, b: number): number {46- if (b === 0) throw new Error("rs: divide by zero");47- if (a === 0) return 0;48- return EXP_TABLE[(LOG_TABLE[a] + 255 - LOG_TABLE[b]) % 255];49-}50-51-function gfPow(x: number, power: number): number {52- if (x === 0) return 0;53- return EXP_TABLE[(LOG_TABLE[x] * power) % 255];54-}55-56-function gfInv(x: number): number {57- if (x === 0) throw new Error("rs: inverse of zero");58- return EXP_TABLE[(255 - LOG_TABLE[x]) % 255];59-}60-61-// ── Polynomial helpers (coefficients high→low; out[0] = x^(n-1)) ────────────62-63-function polyScale(p: Uint8Array, x: number): Uint8Array {64- const r = new Uint8Array(p.length);65- for (let i = 0; i < p.length; i++) r[i] = gfMul(p[i], x);66- return r;67-}68-69-function polyAdd(p: Uint8Array, q: Uint8Array): Uint8Array {70- const len = Math.max(p.length, q.length);71- const r = new Uint8Array(len);72- for (let i = 0; i < p.length; i++) r[i + len - p.length] = p[i];73- for (let i = 0; i < q.length; i++) r[i + len - q.length] ^= q[i];74- return r;75-}76-77-function polyMul(p: Uint8Array, q: Uint8Array): Uint8Array {78- const r = new Uint8Array(p.length + q.length - 1);79- for (let j = 0; j < q.length; j++) {80- if (q[j] === 0) continue;81- for (let i = 0; i < p.length; i++) {82- if (p[i] === 0) continue;83- r[i + j] ^= gfMul(p[i], q[j]);84- }85- }86- return r;87-}88-89-function polyEval(p: Uint8Array, x: number): number {90- let y = p[0];91- for (let i = 1; i < p.length; i++) y = gfMul(y, x) ^ p[i];92- return y;93-}94-95-function generatorPoly(parity: number): Uint8Array {96- let g: Uint8Array = new Uint8Array([1]);97- for (let i = 0; i < parity; i++) {98- g = polyMul(g, new Uint8Array([1, EXP_TABLE[i]]));99- }100- return g;101-}102-103-// Cache generators per parity length — only one is used in practice.104-const generatorCache = new Map<number, Uint8Array>();105-function getGenerator(parity: number): Uint8Array {106- let g = generatorCache.get(parity);107- if (!g) {108- g = generatorPoly(parity);109- generatorCache.set(parity, g);110- }111- return g;112-}113-114-// ── Block encode/decode ─────────────────────────────────────────────────────115-116-function encodeBlock(data: Uint8Array, parity: number): Uint8Array {117- const gen = getGenerator(parity);118- const out = new Uint8Array(data.length + parity);119- out.set(data);120- for (let i = 0; i < data.length; i++) {121- const coef = out[i];122- if (coef !== 0) {123- for (let j = 1; j < gen.length; j++) {124- out[i + j] ^= gfMul(gen[j], coef);125- }126- }127- }128- // First `data.length` bytes still hold the original message; replace with129- // it (it was used as scratch above) and copy the parity tail unchanged.130- out.set(data);131- return out;132-}133-134-function calcSyndromes(received: Uint8Array, parity: number): Uint8Array {135- const synd = new Uint8Array(parity + 1);136- synd[0] = 0;137- for (let i = 0; i < parity; i++) {138- synd[i + 1] = polyEval(received, EXP_TABLE[i]);139- }140- return synd;141-}142-143-function findErrorLocator(synd: Uint8Array, parity: number): Uint8Array | null {144- let errLoc: Uint8Array = new Uint8Array([1]);145- let oldLoc: Uint8Array = new Uint8Array([1]);146-147- for (let i = 0; i < parity; i++) {148- oldLoc = new Uint8Array([...oldLoc, 0]);149- let delta = synd[i + 1];150- for (let j = 1; j < errLoc.length; j++) {151- delta ^= gfMul(errLoc[errLoc.length - 1 - j], synd[i + 1 - j]);152- }153- if (delta !== 0) {154- if (oldLoc.length > errLoc.length) {155- const newLoc = polyScale(oldLoc, delta);156- oldLoc = polyScale(errLoc, gfInv(delta));157- errLoc = newLoc;158- }159- errLoc = polyAdd(errLoc, polyScale(oldLoc, delta));160- }161- }162-163- // Strip leading zeros164- let lead = 0;165- while (lead < errLoc.length && errLoc[lead] === 0) lead++;166- errLoc = errLoc.slice(lead);167-168- const errs = errLoc.length - 1;169- if (errs * 2 > parity) return null; // too many errors170- return errLoc;171-}172-173-function findErrors(errLoc: Uint8Array, length: number): number[] | null {174- // Λ has roots at α^(-poly_degree). Chien finds Λ(α^j)=0 at j = (b+1) mod 255175- // for byte index b in a length-255 codeword. So b = (j - 1) mod 255.176- // For our use this is always full RS(255,178) blocks; the modulus is 255.177- const errs = errLoc.length - 1;178- const positions: number[] = [];179- const N = 255;180- for (let i = 0; i < N; i++) {181- if (polyEval(errLoc, gfPow(2, i)) === 0) {182- const byteIdx = (i - 1 + N) % N;183- if (byteIdx < length) positions.push(byteIdx);184- }185- }186- if (positions.length !== errs) return null;187- return positions;188-}189-190-function correctErrors(191- received: Uint8Array,192- synd: Uint8Array,193- errPositions: number[],194-): Uint8Array {195- const coefPos = errPositions.map((p) => received.length - 1 - p);196- // Forney syndrome197- const errLocPoly = (() => {198- let p: Uint8Array = new Uint8Array([1]);199- for (const pos of coefPos) {200- p = polyMul(p, polyAdd(new Uint8Array([1]), new Uint8Array([gfPow(2, pos), 0])));201- }202- return p;203- })();204-205- const syndRev = new Uint8Array(synd.length - 1);206- for (let i = 0; i < syndRev.length; i++) syndRev[i] = synd[synd.length - 1 - i];207- let omega: Uint8Array = polyMul(syndRev, errLocPoly);208- omega = omega.slice(omega.length - errLocPoly.length + 1);209-210- // Formal derivative of errLocPoly (skip even-degree terms)211- const errLocDeriv = new Uint8Array(errLocPoly.length - 1);212- for (let i = 0; i < errLocDeriv.length; i++) {213- const deg = errLocPoly.length - 1 - i;214- if (deg % 2 === 1) errLocDeriv[i] = errLocPoly[i];215- }216-217- const out = new Uint8Array(received);218- for (let i = 0; i < coefPos.length; i++) {219- const pos = coefPos[i];220- const xi = gfPow(2, pos);221- const xiInv = gfInv(xi);222- const num = polyEval(omega, xiInv);223- const den = polyEval(errLocDeriv, xiInv);224- if (den === 0) throw new Error("rs: denominator zero in Forney");225- const magnitude = gfMul(xi, gfDiv(num, den));226- out[received.length - 1 - pos] ^= magnitude;227- }228- return out;229-}230-231-function decodeBlock(received: Uint8Array, parity: number): Uint8Array | null {232- const synd = calcSyndromes(received, parity);233- let hasError = false;234- for (let i = 1; i < synd.length; i++) {235- if (synd[i] !== 0) { hasError = true; break; }236- }237- if (!hasError) return received.slice(0, received.length - parity);238-239- const errLoc = findErrorLocator(synd, parity);240- if (!errLoc) return null;241-242- const positions = findErrors(errLoc, received.length);243- if (!positions) return null;244-245- let corrected: Uint8Array;246- try {247- corrected = correctErrors(received, synd, positions);248- } catch {249- return null;250- }251-252- // Verify by recomputing syndromes253- const synd2 = calcSyndromes(corrected, parity);254- for (let i = 1; i < synd2.length; i++) {255- if (synd2[i] !== 0) return null;256- }257- return corrected.slice(0, received.length - parity);258-}259-260-// ── Public block-mode API ───────────────────────────────────────────────────261-262-/**263- * Encode `data` with Reed-Solomon. Splits into BLOCK_DATA-byte data blocks264- * (zero-padded), appends BLOCK_PARITY parity bytes per block, returns the265- * concatenation. `_advisoryParityBytes` is accepted for spec compatibility266- * and ignored — the actual parity volume is fixed by the block geometry.267- */268-export function reedSolomonEncode(269- data: Uint8Array,270- _advisoryParityBytes?: number,271-): Uint8Array {272- if (data.length === 0) return new Uint8Array(0);273- const blocks = Math.ceil(data.length / BLOCK_DATA);274- const out = new Uint8Array(blocks * BLOCK_TOTAL);275- for (let b = 0; b < blocks; b++) {276- const start = b * BLOCK_DATA;277- const slice = data.slice(start, start + BLOCK_DATA);278- const padded = slice.length === BLOCK_DATA279- ? slice280- : (() => { const p = new Uint8Array(BLOCK_DATA); p.set(slice); return p; })();281- const enc = encodeBlock(padded, BLOCK_PARITY);282- out.set(enc, b * BLOCK_TOTAL);283- }284- return out;285-}286-287-/**288- * Decode an RS-encoded stream. Splits into BLOCK_TOTAL-byte received blocks289- * and decodes each. `dataBytes` is the original payload length; the result290- * is trimmed accordingly. Throws if any block fails (too many errors).291- *292- * `_parityBytes` is accepted for spec compatibility; the per-block parity293- * count is fixed by the block geometry.294- */295-export function reedSolomonDecode(296- received: Uint8Array,297- dataBytes: number,298- _parityBytes?: number,299-): Uint8Array {300- if (dataBytes === 0) return new Uint8Array(0);301- if (received.length % BLOCK_TOTAL !== 0) {302- throw new Error(303- `rs: received length ${received.length} not a multiple of ${BLOCK_TOTAL}`,304- );305- }306- const blocks = received.length / BLOCK_TOTAL;307- const out = new Uint8Array(blocks * BLOCK_DATA);308- for (let b = 0; b < blocks; b++) {309- const slice = received.slice(b * BLOCK_TOTAL, (b + 1) * BLOCK_TOTAL);310- const decoded = decodeBlock(slice, BLOCK_PARITY);311- if (!decoded) throw new Error(`rs: block ${b} uncorrectable`);312- out.set(decoded, b * BLOCK_DATA);313- }314- return out.slice(0, dataBytes);315-}316-317-/** Constants the Mayan layer needs for capacity math. */318-export const RS_BLOCK_DATA = BLOCK_DATA;319-export const RS_BLOCK_PARITY = BLOCK_PARITY;320-export const RS_BLOCK_TOTAL = BLOCK_TOTAL;321-322-// ── Per-block API (Mayan v2 uses these directly with parity=51) ─────────────323-324-/**325- * Encode a single shortened RS codeword: append `parity` parity bytes to326- * `data`, returning a `data.length + parity`-byte block. Caller controls327- * the block geometry; for RS(255,204) pass 204-byte data + parity=51.328- */329-export function rsEncodeBlock(data: Uint8Array, parity: number): Uint8Array {330- return encodeBlock(data, parity);331-}332-333-/**334- * Decode a single shortened RS codeword. Returns the data portion (without335- * parity) on success, or null if uncorrectable. Block length and parity must336- * match what was used at encode time.337- */338-export function rsDecodeBlock(received: Uint8Array, parity: number): Uint8Array | null {339- return decodeBlock(received, parity);340-}src/popup/styles/popup.css+26 −60
@@ -1641,18 +1641,6 @@16411641 }16421642 16431643 .peach-script-input::-webkit-scrollbar { display: none; }1644-.peach-qr-canvas-wrap {1645- display: flex;1646- justify-content: center;1647- padding: 12px 0 6px;1648-}1649-.peach-qr-canvas-wrap canvas {1650- display: block;1651- background: #ffffff;1652- border-radius: 6px;1653- max-width: 100%;1654- height: auto;1655-}16561644 .peach-csv-warning {16571645 background: color-mix(in srgb, var(--peach-breach) 14%, transparent);16581646 border: 1px solid color-mix(in srgb, var(--peach-breach) 40%, transparent);@@ -1744,9 +1732,6 @@17441732 background: rgba(220,80,60,0.08);17451733 color: var(--peach-breach);17461734 }1747-.peach-export-password-stack {1748- gap: 8px;1749-}17501735 .peach-settings-label {17511736 margin: 0 0 6px;17521737 font-family: "IBM Plex Mono", monospace;@@ -3635,7 +3620,6 @@36353620 /* ── Shared tab inner layout ──────────────────────────────────────────── */36363621 36373622 .peach-rt-symbols,3638-.peach-rt-mayan,36393623 .peach-rt-csv,36403624 .peach-rt-phone {36413625 display: flex;@@ -3713,43 +3697,6 @@37133697 display: block;37143698 }37153699 3716-/* ── Mayan Code tab ───────────────────────────────────────────────────── */3717-3718-.peach-rt-upload-zone {3719- display: flex;3720- align-items: center;3721- justify-content: center;3722- height: 64px;3723- border-radius: 10px;3724- border: 1px dashed var(--peach-t3);3725- background: rgba(240, 232, 216, 0.02);3726- cursor: pointer;3727- transition: border-color 150ms ease, background 150ms ease;3728- overflow: hidden;3729-}3730-.peach-rt-upload-zone:hover {3731- border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);3732- background: color-mix(in srgb, var(--peach-accent) 4%, transparent);3733-}3734-.peach-rt-upload-zone.is-busy {3735- cursor: default;3736- opacity: 0.6;3737-}3738-3739-.peach-rt-upload-label {3740- font-size: 0.75rem;3741- color: var(--peach-t2);3742- font-style: italic;3743-}3744-3745-.peach-rt-entry-line {3746- margin: 0;3747- font-size: 0.75rem;3748- color: var(--peach-t1);3749- font-style: italic;3750- text-align: center;3751-}3752-37533700 /* ── CSV tab ──────────────────────────────────────────────────────────── */37543701 37553702 .peach-rt-csv-sources {@@ -3798,13 +3745,6 @@37983745 text-align: center;37993746 }38003747 3801-.peach-rt-phone-gate-text {3802- margin: 0;3803- font-size: 0.8125rem;3804- color: var(--peach-t1);3805- text-align: center;3806-}3807-38083748 /* ── Intro popup ──────────────────────────────────────────────────────── */38093749 38103750 .peach-restore-intro-overlay {@@ -6486,6 +6426,32 @@64866426 .peach-onboarding-next:disabled {64876427 opacity: 0.5;64886428 cursor: default;6429+}6430+6431+.peach-onboarding-back {6432+ background: none;6433+ border: none;6434+ padding: 4px 0;6435+ cursor: pointer;6436+ font-family: "IBM Plex Mono", monospace;6437+ font-size: 0.6875rem;6438+ letter-spacing: 0.04em;6439+ color: var(--peach-t2);6440+ transition: color 150ms ease;6441+}6442+6443+.peach-onboarding-back:hover {6444+ color: var(--peach-t0);6445+}6446+6447+.peach-onboarding-back:disabled {6448+ opacity: 0.5;6449+ cursor: default;6450+}6451+6452+/* Reserve back-button width on the first card so the dots stay centred */6453+.peach-onboarding-back-spacer {6454+ width: 44px;64896455 }64906456 64916457 .peach-onboarding-mobile-card-buttons {src/popup/views/Export.tsx+1 −167
@@ -8,10 +8,9 @@88 import { buildPayload } from "../../crypto/peachPayload";99 import { encodePeachScript } from "../../crypto/peachScript";1010 import { RuneCanvas } from "../components/RuneCanvas";11-import { encodeMayan, MAX_PAYLOAD_BYTES as MAYAN_MAX_BYTES } from "../../crypto/mayan";1211 import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1312 14-type ExportSubview = "menu" | "peachscript" | "qr" | "csv";13+type ExportSubview = "menu" | "peachscript" | "csv";1514 1615 interface ExportProps {1716 onBack: () => void;@@ -29,9 +28,6 @@2928 <Show when={view() === "peachscript"}>3029 <PeachScriptExport onBack={() => setView("menu")} />3130 </Show>32- <Show when={view() === "qr"}>33- <QRExport onBack={() => setView("menu")} />34- </Show>3531 <Show when={view() === "csv"}>3632 <CSVExport onBack={() => setView("menu")} />3733 </Show>@@ -63,20 +59,6 @@6359 </div>6460 6561 <div class="peach-settings-group peach-export-card">66- <p class="peach-settings-label">print backup</p>67- <p class="peach-export-card-copy">68- Save a Mayan code you can print and store. Scan it with Peach on any69- device to restore.70- </p>71- <button72- class="peach-btn peach-btn--subtle peach-btn--sm"73- onClick={() => props.onPick("qr")}74- >75- Save Mayan code →76- </button>77- </div>78-79- <div class="peach-settings-group peach-export-card">8062 <p class="peach-settings-label">CSV (leaving peach)</p>8163 <p class="peach-export-card-copy">8264 Plain-text export for moving into another password manager. Anyone@@ -260,154 +242,6 @@260242 <p class="peach-export-card-copy" style="margin-top:8px">261243 Paste the script — or import the .peach file — into Peach on262244 your other device to restore your vault. Won't work anywhere else.263- </p>264- </div>265- </Show>266- <Show when={error()}>267- <div class="peach-settings-group">268- <div class="peach-restore-error">{error()}</div>269- </div>270- </Show>271- </div>272- </>273- );274-}275-276-// ---------------------------------------------------------------------------277-// QR278-// ---------------------------------------------------------------------------279-280-function QRExport(props: { onBack: () => void }) {281- const [pass, setPass] = createSignal("");282- const [confirm, setConfirm] = createSignal("");283- const [working, setWorking] = createSignal(false);284- const [pngUrl, setPngUrl] = createSignal<string | null>(null);285- const [error, setError] = createSignal<string | null>(null);286- const [entryCount, setEntryCount] = createSignal(0);287- let canvasHost: HTMLDivElement | undefined;288-289- const passReady = createMemo(290- () => pass().length >= MIN_BACKUP_PASSWORD && pass() === confirm()291- );292-293- async function handleEncode() {294- setError(null);295- setPngUrl(null);296- if (!passReady()) {297- setError("Backup passwords don't match or are too short.");298- return;299- }300- setWorking(true);301- try {302- const env = await assembleEnvelope(pass());303- if (env.bytes.length > MAYAN_MAX_BYTES) {304- setError(305- `This vault is too large for a Mayan code backup (${env.bytes.length} bytes). Use PeachScript instead.`,306- );307- return;308- }309- setEntryCount(env.entryCount);310- // On-screen render at 3px/module; download render at 4px/module.311- const screen = encodeMayan(env.bytes, 3);312- const print = encodeMayan(env.bytes, 4);313- if (screen.paperSize === "A3") {314- setError(315- `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — print on A3 paper for best results.`,316- );317- } else if (screen.paperSize === "A3-required") {318- setError(319- `Mayan symbol is ${screen.symbolSize}×${screen.symbolSize} modules — A3 paper required to print at full resolution. Save as PNG for digital backup.`,320- );321- }322- if (canvasHost) {323- canvasHost.innerHTML = "";324- canvasHost.appendChild(screen.canvas);325- }326- setPngUrl(print.canvas.toDataURL("image/png"));327- } catch (err) {328- setError(err instanceof Error ? err.message : String(err));329- } finally {330- setWorking(false);331- }332- }333-334- function handleSave() {335- const url = pngUrl();336- if (!url) return;337- const a = document.createElement("a");338- a.href = url;339- a.download = `peach-mayan-backup-${dateStamp()}.png`;340- document.body.appendChild(a);341- a.click();342- document.body.removeChild(a);343- }344-345- return (346- <>347- <OverlayHeader title="print backup" onBack={props.onBack} />348- <div class="peach-settings-body">349- <Show350- when={pngUrl()}351- fallback={352- <div class="peach-settings-group peach-export-card peach-export-card--primary">353- <p class="peach-settings-label">backup password</p>354- <p class="peach-export-card-copy">355- Set a password to lock this Mayan code backup. You'll need it356- to restore — without it the printed code is unreadable.357- </p>358- <div class="peach-settings-stack peach-export-password-stack">359- <input360- ref={(el) => queueMicrotask(() => el.focus())}361- class="peach-settings-input"362- type="password"363- placeholder={`backup password (min ${MIN_BACKUP_PASSWORD} chars)`}364- value={pass()}365- onInput={(e) => setPass((e.target as HTMLInputElement).value)}366- onKeyDown={(e) => {367- if (e.key === "Enter" && passReady() && !working()) {368- e.preventDefault();369- void handleEncode();370- }371- }}372- />373- <input374- class="peach-settings-input"375- type="password"376- placeholder="confirm backup password"377- value={confirm()}378- onInput={(e) => setConfirm((e.target as HTMLInputElement).value)}379- onKeyDown={(e) => {380- if (e.key === "Enter" && passReady() && !working()) {381- e.preventDefault();382- void handleEncode();383- }384- }}385- />386- </div>387- <button388- class="peach-btn peach-btn--subtle peach-btn--sm"389- onClick={() => void handleEncode()}390- disabled={working() || !passReady()}391- >392- {working() ? "encoding…" : "Generate Mayan code"}393- </button>394- </div>395- }396- >397- <div class="peach-settings-group peach-export-card peach-export-card--primary">398- <div class="peach-qr-canvas-wrap" ref={canvasHost} />399- <p class="peach-export-card-copy" style="text-align:center">400- {entryCount()} entr{entryCount() === 1 ? "y" : "ies"} · {monthYear()}401- </p>402- <button403- class="peach-btn peach-btn--subtle peach-btn--sm"404- onClick={handleSave}405- >406- Save PNG407- </button>408- <p class="peach-export-card-copy" style="margin-top:8px">409- Print this and keep it somewhere safe. Scan it with Peach to410- restore your vault on any device.411245 </p>412246 </div>413247 </Show>src/popup/views/Lock.tsx+7 −232
@@ -48,11 +48,7 @@4848 const [confirmPassword, setConfirmPassword] = createSignal("");4949 const [error, setError] = createSignal("");5050 const [loading, setLoading] = createSignal(false);51- // If the popup was reopened as a detached window for file picking, go straight52- // to the restore view so the user lands where they intended.53- const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(54- new URLSearchParams(location.search).has("detached") ? "restore" : null55- );51+ const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);5652 const [showPassword, setShowPassword] = createSignal(false);5753 const [recoverSurface, setRecoverSurface] = createSignal<RecoverSurface>(null);5854 // After vault creation succeeds, set this to show the recovery words screen.@@ -159,28 +155,11 @@159155 setLoading(false);160156 }161157 162- async function openRestore(surface: Exclude<RestoreSurface, null>) {158+ function openRestore(surface: Exclude<RestoreSurface, null>) {163159 if (!props.ready || loading()) return;164160 setPassword("");165161 setConfirmPassword("");166162 setError("");167-168- // The toolbar popup closes when a native file dialog steals focus (macOS).169- // Reopen as a detached chrome.windows.create popup, which survives focus170- // loss. The detached window initialises directly into the restore view via171- // the ?detached URL param.172- const isDetached = new URLSearchParams(location.search).has("detached");173- if (!isDetached) {174- try {175- const url = chrome.runtime.getURL("src/popup/index.html") + "?detached=1";176- await chrome.windows.create({ url, type: "popup", width: 340, height: 560, focused: true });177- window.close();178- return;179- } catch {180- // Not in extension context (dev mode) — fall through to direct open.181- }182- }183-184163 setRestoreSurface(surface);185164 }186165 @@ -191,7 +170,6 @@191170 <Match when={restoreSurface() === "restore"}>192171 <RestoreTabView193172 hasVault={props.hasVault}194- isPro={props.isPro ?? false}195173 onBack={() => setRestoreSurface(null)}196174 onUnlock={async () => {197175 setRestoreSurface(null);@@ -440,17 +418,16 @@440418 441419 // ── Restore tab view ───────────────────────────────────────────────────────442420 443-type RestoreTab = "symbols" | "mayan" | "csv" | "phone";421+type RestoreTab = "symbols" | "csv" | "phone";444422 445423 function tabLabel(tab: RestoreTab): string {446424 return (447- { symbols: "PeachScript", mayan: "Mayan Code", csv: "CSV", phone: "From Phone" }[tab]425+ { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]448426 );449427 }450428 451429 function RestoreTabView(props: {452430 hasVault: boolean;453- isPro: boolean;454431 onBack: () => void;455432 onUnlock: () => void | Promise<void>;456433 }) {@@ -458,22 +435,12 @@458435 const [showIntro, setShowIntro] = createSignal(false);459436 const [pendingUnlock, setPendingUnlock] = createSignal(false);460437 461- const isDetachedWindow = new URLSearchParams(location.search).has("detached");462-463- async function reattachPopup() {464- if (!isDetachedWindow) return;465- // Reopen the normal toolbar popup then discard the detached window.466- try { await (chrome.action as { openPopup?: () => Promise<void> }).openPopup?.(); } catch {}467- window.close();468- }469-470438 async function handleRestoreSuccess() {471439 const seen = await storageGet("peach_shown_restore_intro").catch(() => null);472440 if (!seen) {473441 setShowIntro(true);474442 } else {475443 await props.onUnlock();476- await reattachPopup();477444 }478445 }479446 @@ -481,7 +448,6 @@481448 await storageSet("peach_shown_restore_intro", "true").catch(() => {});482449 setShowIntro(false);483450 await props.onUnlock();484- await reattachPopup();485451 }486452 487453 return (@@ -500,7 +466,7 @@500466 501467 <div class="peach-newvault-card peach-restore-tabcard">502468 <div class="peach-restore-tabs" role="tablist">503- <For each={["symbols", "mayan", "csv", "phone"] as RestoreTab[]}>469+ <For each={["symbols", "csv", "phone"] as RestoreTab[]}>504470 {(tab) => (505471 <button506472 class="peach-restore-tab"@@ -518,14 +484,11 @@518484 <Show when={activeTab() === "symbols"}>519485 <RestoreSymbols hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />520486 </Show>521- <Show when={activeTab() === "mayan"}>522- <RestoreMayan hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />523- </Show>524487 <Show when={activeTab() === "csv"}>525488 <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />526489 </Show>527490 <Show when={activeTab() === "phone"}>528- <RestoreFromPhone isPro={props.isPro} onSuccess={handleRestoreSuccess} />491+ <RestoreFromPhone onSuccess={handleRestoreSuccess} />529492 </Show>530493 </div>531494 </div>@@ -554,39 +517,6 @@554517 );555518 }556519 557-// ── Mayan image decode helper ──────────────────────────────────────────────558-559-async function handleMayanDecode(base64: string): Promise<560- { ok: true; payload: Uint8Array } | { ok: false; reason: string }561-> {562- const { decodeMayan } = await import("../../crypto/mayan");563- const img = new Image();564- const dataUrl = base64.startsWith("data:") ? base64 : `data:image/*;base64,${base64}`;565- await new Promise<void>((resolve, reject) => {566- img.onload = () => resolve();567- img.onerror = () => reject(new Error("Couldn't read the image."));568- img.src = dataUrl;569- });570- const canvas = document.createElement("canvas");571- // Cap resolution so adaptive-binarize stays bounded.572- const MAX_DIM = 1500;573- const scale = Math.min(1, MAX_DIM / Math.max(img.width, img.height));574- canvas.width = Math.round(img.width * scale);575- canvas.height = Math.round(img.height * scale);576- const ctx = canvas.getContext("2d");577- if (!ctx) return { ok: false, reason: "Couldn't open a 2D canvas." };578- ctx.drawImage(img, 0, 0, canvas.width, canvas.height);579- const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);580- const payload = decodeMayan(imageData);581- if (!payload) {582- return {583- ok: false,584- reason: "Could not read this Mayan code. Try a clearer image.",585- };586- }587- return { ok: true, payload };588-}589-590520 // ── PeachScript tab ────────────────────────────────────────────────────────591521 592522 function RestoreSymbols(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {@@ -856,139 +786,6 @@856786 );857787 }858788 859-// ── Mayan Code tab ─────────────────────────────────────────────────────────860-861-function RestoreMayan(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {862- const [decoding, setDecoding] = createSignal(false);863- const [decoded, setDecoded] = createSignal<Uint8Array | null>(null);864- const [entryLine, setEntryLine] = createSignal<string | null>(null);865- const [backupPass, setBackupPass] = createSignal("");866- const [restoring, setRestoring] = createSignal(false);867- const [error, setError] = createSignal<string | null>(null);868-869- async function processFile(file: File) {870- setDecoding(true);871- setDecoded(null);872- setEntryLine(null);873- setError(null);874- try {875- const { decodeMayan } = await import("../../crypto/mayan");876- const bitmap = await createImageBitmap(file);877- const MAX_DIM = 1500;878- const scale = Math.min(1, MAX_DIM / Math.max(bitmap.width, bitmap.height));879- const canvas = document.createElement("canvas");880- canvas.width = Math.round(bitmap.width * scale);881- canvas.height = Math.round(bitmap.height * scale);882- const ctx = canvas.getContext("2d")!;883- ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);884- bitmap.close();885- const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);886- const payload = decodeMayan(imageData);887- if (!payload) { setError("Could not read this Mayan code. Try a clearer image."); return; }888- setDecoded(payload);889- } catch {890- setError("Could not read this Mayan code. Try a clearer image.");891- } finally {892- setDecoding(false);893- }894- }895-896- async function handleRestore() {897- const bytes = decoded();898- if (!bytes || !backupPass().trim() || restoring()) return;899- setRestoring(true);900- setError(null);901- try {902- const payload = await parsePayload(bytes, backupPass());903- const count = payload.entries.length;904- const date = new Date(905- Math.max(...payload.entries.map((e) => e.modifiedAt ?? e.createdAt ?? 0))906- );907- const monthYear = date.getFullYear() > 2000908- ? date.toLocaleDateString("en-US", { month: "long", year: "numeric" })909- : null;910- setEntryLine(monthYear ? `${count} entries · ${monthYear}` : `${count} entries`);911-912- if (!props.hasVault) {913- const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: backupPass() } });914- if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");915- }916- const rr = await sendMessage({ type: "RESTORE_ENTRIES", payload: { entries: payload.entries, mode: "merge", settings: payload.settings } });917- if (!(rr as { ok: boolean }).ok) throw new Error((rr as { error?: string }).error ?? "RESTORE_FAILED");918- if (payload.license?.key) {919- await sendMessage({ type: "ACTIVATE_LICENSE", payload: { key: payload.license.key } }).catch(() => {});920- }921- await props.onSuccess();922- } catch (err) {923- const code = err instanceof Error ? err.message : String(err);924- setError(925- code === "INVALID_PASSPHRASE" || code === "INVALID_EXPORT_PASSWORD"926- ? "Wrong backup password."927- : code === "CREATE_VAULT_FAILED"928- ? "Couldn't create a new local vault."929- : "Restore failed. The backup may be corrupted.",930- );931- } finally {932- setRestoring(false);933- }934- }935-936- return (937- <div class="peach-rt-mayan">938- <Show when={decoded() === null}>939- <label class="peach-rt-upload-zone" classList={{ "is-busy": decoding() }}>940- <span class="peach-rt-upload-label">941- {decoding() ? "decoding…" : "tap to upload image"}942- </span>943- <input944- type="file"945- accept="image/*"946- style="display:none"947- onChange={(e) => {948- const file = (e.currentTarget as HTMLInputElement).files?.[0];949- if (file) void processFile(file);950- (e.currentTarget as HTMLInputElement).value = "";951- }}952- disabled={decoding()}953- />954- </label>955- <Show when={error()}>956- <p class="peach-rt-error">{error()}</p>957- </Show>958- </Show>959- <Show when={decoded() !== null}>960- <Show when={entryLine()}>961- <p class="peach-rt-entry-line">{entryLine()}</p>962- </Show>963- <input964- class="peach-restore-symbols-pass"965- type="password"966- placeholder="backup password"967- ref={(el) => queueMicrotask(() => el.focus())}968- value={backupPass()}969- onInput={(e) => setBackupPass((e.target as HTMLInputElement).value)}970- onKeyDown={(e) => {971- if (e.key === "Enter" && !restoring()) { e.preventDefault(); void handleRestore(); }972- }}973- />974- <button975- class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"976- onClick={() => void handleRestore()}977- disabled={!backupPass().trim() || restoring()}978- >979- {restoring() ? "restoring…" : "restore vault →"}980- </button>981- <button class="peach-btn peach-btn--ghost peach-btn--sm" style="margin-top:4px" onClick={() => { setDecoded(null); setEntryLine(null); setError(null); }}>982- ← try another image983- </button>984- <Show when={error()}>985- <p class="peach-rt-error">{error()}</p>986- </Show>987- </Show>988- </div>989- );990-}991-992789 // ── CSV tab ────────────────────────────────────────────────────────────────993790 994791 function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {@@ -1061,7 +858,7 @@1061858 1062859 type PhoneState = "waiting" | "connected" | "trust" | "error";1063860 1064-function RestoreFromPhone(props: { isPro: boolean; onSuccess: () => void | Promise<void> }) {861+function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {1065862 const [state, setState] = createSignal<PhoneState>("waiting");1066863 const [code, setCode] = createSignal<string | null>(null);1067864 const [qrUrl, setQrUrl] = createSignal<string | null>(null);@@ -1116,7 +913,6 @@1116913 }1117914 1118915 onMount(() => {1119- if (!props.isPro) return;1120916 void startPairing();1121917 // @ts-ignore — chrome runtime1122918 if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {@@ -1139,27 +935,6 @@1139935 await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1140936 }1141937 await props.onSuccess();1142- }1143-1144- if (!props.isPro) {1145- return (1146- <div class="peach-rt-phone">1147- <p class="peach-rt-phone-gate-text">Transfer requires Peach Pro.</p>1148- <button1149- class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1150- onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro" })}1151- >1152- Get Peach Pro1153- </button>1154- <button1155- class="peach-btn peach-btn--ghost peach-btn--sm"1156- style="margin-top:6px"1157- onClick={() => chrome.tabs.create({ url: "https://peachpasswords.com/pro#redeem" })}1158- >1159- I already have a license1160- </button>1161- </div>1162- );1163938 }1164939 1165940 return (src/popup/views/Onboarding.tsx+25 −3
@@ -21,8 +21,8 @@2121 {2222 headline: "Two keys.",2323 body: [24- "Your master password is yours alone. Peach cannot see it or reset it.",25- "Forget it, and the 12 recovery words you save at setup are the only way back in.",24+ "Your master password is yours alone. Peach never sees it.",25+ "If you forget it, your 12 recovery words can reset it from settings — save them somewhere safe.",2626 ],2727 warning: "Lose both and your vault cannot be opened.",2828 },@@ -31,7 +31,7 @@3131 body: [3232 "Passwords, passkeys, cards, secure notes, two-factor codes.",3333 "Autofills on every site. Generates strong passwords. Detects breaches.",34- "Free forever. Pro adds sync and passkeys.",34+ "Free forever. Pro adds sync, passkeys, and advanced vault health.",3535 ],3636 warning: null,3737 },@@ -72,6 +72,16 @@7272 }7373 }7474 75+ function goBack() {76+ if (exiting() || completing()) return;77+ if (card() === 0) return;78+ setExiting(true);79+ window.setTimeout(() => {80+ setCard((c) => c - 1);81+ setExiting(false);82+ }, CARD_EXIT_MS + CARD_ENTER_GAP_MS);83+ }84+7585 return (7686 <section7787 class="peach-screen peach-theme peach-onboarding"@@ -112,6 +122,18 @@112122 </div>113123 114124 <div class="peach-onboarding-nav">125+ <Show when={card() > 0} fallback={<div class="peach-onboarding-back-spacer" />}>126+ <button127+ class="peach-onboarding-back"128+ onClick={(e) => {129+ e.stopPropagation();130+ goBack();131+ }}132+ disabled={completing()}133+ >134+ ← back135+ </button>136+ </Show>115137 <div class="peach-onboarding-dots" aria-hidden="true">116138 <For each={CARDS}>117139 {(_, i) => (