// The `.recipe` file the app writes on EXPORT and reads back on IMPORT. It is a // small XML envelope around a scrambled hex payload, and the landing page's QR // card hands a photo's look back as exactly this file, so the server has to // write the app's own format. The source of truth is the web app's // `shared/utils/recipeShare.ts`; this module and the phone app's // `src/utils/recipeShare.ts` are its copies, and `test/security.mjs` pins the // envelope's shape. Change all three together, or the QR stops importing. // // ponytail: obfuscation, not cryptography — the key ships inside the app. Same // caveat as the app's copy: swap in a real cipher at these two functions if the // envelope ever has to resist a determined reader. const KEY = 'RecipesCam::recipe-share::v1'; const ALGORITHM = 'xor16-v1'; const hash = (s: string): number => { let h = 0x811c9dc5; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; }; // xorshift32: one 16-bit word of keystream per call. const stream = (seed: number) => { let s = seed >>> 0 || 0x9e3779b9; return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s & 0xffff; }; }; // 16-bit code units, hex-encoded: no encoder needed, every UTF-16 char (names // with Vietnamese diacritics included) survives the round trip. const scramble = (text: string, salt: number): string => { const k = stream(hash(`${KEY}|${salt}`)); let out = ''; for (let i = 0; i < text.length; i++) out += (text.charCodeAt(i) ^ k()).toString(16).padStart(4, '0'); return out; }; // The recipe as the app stored it, wrapped in the envelope. The payload is the // stored JSON whole: the parser on the other side keeps the fields it knows and // drops the rest, so this side needs no opinion about what a recipe contains. export function recipeFile(recipeJson: string): string { const salt = Math.floor(Math.random() * 0xffffffff) >>> 0; return [ '', ``, ` ${scramble(recipeJson, salt)}`, '', '', ].join('\n'); }