peachScript.ts

3.2 KB · raw

1/**2 * peachScript.ts — PeachScript encoding/decoding.3 *4 * PeachScript is the device-to-device export format. Each byte of an5 * encrypted vault payload maps to one Unicode Private Use Area code point6 * in U+E000..U+E0FF. The bundled `PeachScript-Regular.otf` renders those7 * 256 code points as the visual script. Outside Peach the string renders8 * as boxes — that is the desired property: PeachScript is meant to be9 * read by Peach, not by humans.10 *11 * The encoding is trivially reversible: byte 0 → U+E000, byte 255 →12 * U+E0FF. No padding, no base conversion, no alignment fix-ups. The font13 * does the visual work; this module is the codec.14 */1516const BASE = 0xe000;1718// PB-26-010: decode allocates one byte per code point, so bound the input at19// the same 10 MiB ceiling parseImportSource applies to CSV/JSON — otherwise a20// pasted or loaded string allocates without bound before any validation.21import { IMPORT_LIMITS } from "../shared/import-limits";2223/**24 * Every encoded code point lands in U+E000..U+E0FF, which UTF-8 encodes as25 * exactly three bytes.26 *27 * PB-26-113: the importer bounds the FILE, so the export ceiling has to be28 * measured in encoded bytes, not envelope bytes. Encoding an N-byte envelope29 * produced a 3N-byte .peach file, so a 4 MiB vault exported a ~12 MiB backup30 * that Peach's own importer then rejected outright — a backup that fails only31 * when it is needed, years later.32 */33const UTF8_BYTES_PER_CODE_POINT = 3;3435/** Encode a binary payload to a PeachScript string (one byte per code point). */36export function encodePeachScript(bytes: Uint8Array): string {37  if (bytes.length * UTF8_BYTES_PER_CODE_POINT > IMPORT_LIMITS.MAX_JSON_BYTES) {38    throw new Error("PEACHSCRIPT_TOO_LARGE");39  }40  const out: string[] = [];41  for (let i = 0; i < bytes.length; i++) {42    out.push(String.fromCodePoint(BASE + bytes[i]));43  }44  return out.join("");45}4647/**48 * Decode a PeachScript string back to bytes. Throws49 * `INVALID_PEACHSCRIPT_CHARACTER` when any character is outside the50 * allotted PUA range — i.e. the string was edited, truncated mid-pair,51 * or is not PeachScript at all.52 */53export function decodePeachScript(str: string): Uint8Array {54  if (str.length > IMPORT_LIMITS.MAX_JSON_BYTES) {55    throw new Error("PEACHSCRIPT_TOO_LARGE");56  }57  const codePoints = Array.from(str);58  const bytes = new Uint8Array(codePoints.length);59  for (let i = 0; i < codePoints.length; i++) {60    const cp = codePoints[i].codePointAt(0)!;61    if (cp < BASE || cp > BASE + 0xff) {62      throw new Error("INVALID_PEACHSCRIPT_CHARACTER");63    }64    bytes[i] = cp - BASE;65  }66  return bytes;67}6869/** Strip whitespace from a PeachScript string before decoding. */70export function normalisePeachScript(str: string): string {71  return str.replace(/\s+/g, "");72}7374/**75 * Cheap pre-flight: every non-whitespace char must be in the PUA range and76 * the string must be non-empty. Used by the import UI to gate the decode77 * button without throwing.78 */79export function isPeachScript(str: string): boolean {80  const trimmed = normalisePeachScript(str);81  if (trimmed.length === 0) return false;82  for (const ch of trimmed) {83    const cp = ch.codePointAt(0)!;84    if (cp < BASE || cp > BASE + 0xff) return false;85  }86  return true;87}