Drop the in-app unlock key: only the PRO apk opens PRO

The set is two apks and the unlock key was a second, weaker lock on the
same door. A verifier that runs offline has to carry its secret inside
the lite apk, so anyone who unpacked it could mint a key. Nothing in the
lite build can flip the gate now.

- entitlement.ts keeps the gate (which look is PRO, what an export
  carries in lite) and exports IS_PRO straight from the build flag.
- SETTINGS loses the key field; the plan chip still says LITE / PRO
  ACTIVE and the helper text says what lite cannot export.
- The upsell alerts just name the build that has the feature.
- tools/keygen.mjs goes with the verifier it fed.
This commit is contained in:
2026-09-14 20:09:12 +07:00
parent 97949e449d
commit 1b60ca2f22
6 changed files with 35 additions and 210 deletions
-72
View File
@@ -1,72 +0,0 @@
// Mint PRO unlock keys for RecipesCam. Dev-side only — never bundled.
// node tools/keygen.mjs 5 mint 5 keys
// node tools/keygen.mjs --from MYCHOSEN12 key for a payload you pick
// node tools/keygen.mjs --check mint 3 keys and verify them (parity guard)
// The key is 12 payload chars + 4 checksum chars, so any 12-char payload works.
// Keep KEY_ALPHABET / KEY_SECRET / checksum identical to
// src/utils/entitlement.ts — the app verifies with the same maths.
import { randomInt } from 'node:crypto';
const KEY_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const KEY_SECRET = 'recipes-cam-key-2026';
const checksum = (payload) => {
let h = 0x811c9dc5; // FNV-1a 32
const src = KEY_SECRET + payload;
for (let i = 0; i < src.length; i++) {
h ^= src.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h & 0xfffff;
};
const keyIsValid = (raw) => {
const s = (raw || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
if (s.length !== 16) return false;
for (let i = 0; i < 16; i++) if (KEY_ALPHABET.indexOf(s[i]) < 0) return false;
const bits = checksum(s.slice(0, 12));
for (let i = 0; i < 4; i++) {
if (KEY_ALPHABET[(bits >>> (i * 5)) & 31] !== s[12 + i]) return false;
}
return true;
};
const format = (s) => s.replace(/(.{4})/g, '$1-').slice(0, -1);
const checkOf = (payload) => {
const bits = checksum(payload);
let check = '';
for (let i = 0; i < 4; i++) check += KEY_ALPHABET[(bits >>> (i * 5)) & 31];
return check;
};
const mint = () => {
let payload = '';
for (let i = 0; i < 12; i++) payload += KEY_ALPHABET[randomInt(KEY_ALPHABET.length)];
return format(payload + checkOf(payload));
};
// Mint the key for a payload you picked yourself — must be 12 chars, alphabet only.
const from = (payload) => {
const p = (payload || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
if (p.length !== 12) throw new Error(`payload must be 12 chars, got ${p.length}: ${p}`);
for (const ch of p) if (KEY_ALPHABET.indexOf(ch) < 0) throw new Error(`'${ch}' is not in ${KEY_ALPHABET}`);
return format(p + checkOf(p));
};
const args = process.argv.slice(2);
if (args[0] === '--check') {
const keys = [mint(), mint(), mint()];
for (const k of keys) {
if (!keyIsValid(k)) throw new Error(`keygen/verify mismatch on ${k}`);
if (keyIsValid(k.slice(0, -1) + (k.endsWith('A') ? 'B' : 'A'))) {
throw new Error(`tampered key accepted: ${k}`);
}
}
console.log('ok', keys.join(' '));
} else if (args[0] === '--from') {
console.log(from(args[1]));
} else {
const n = Number(args[0]) || 1;
for (let i = 0; i < n; i++) console.log(mint());
}
+1 -1
View File
@@ -13,6 +13,6 @@ if (tier !== 'lite' && tier !== 'pro') {
writeFileSync(
new URL('../src/provariant.ts', import.meta.url),
`// Generated by tools/set-variant.mjs - do not edit by hand.\n// false = Lite apk (free, unlockable with a key), true = Pro apk (sold as-is).\nexport const IS_PRO_BUILD = ${tier === 'pro'};\n`
`// Generated by tools/set-variant.mjs - do not edit by hand.\n// false = Lite apk (free, PRO looks are preview-only), true = Pro apk (sold as-is).\nexport const IS_PRO_BUILD = ${tier === 'pro'};\n`
);
console.log('src/provariant.ts ->', tier);