// Checking Garbo's launch data on your server, in Deno, Bun, Cloudflare Workers or any place // with Web Crypto (no packages). https://developers.garbo.ph/#check // // Your page sends Garbo.WebApp.initData to your server; never trust initDataUnsafe there. // - checkHash(): with your app's secret (from Garbo; keep it on your server only). // - checkSignature(): with Garbo's public key below; no secret needed. // Both throw an Error when the data isn't right, and return what's in it when it is. /** Garbo's public key for the launch data's signature (Ed25519, hex). */ export const GARBO_PUBLIC_KEY = 'a6b7e2bcbd35fa46f8040e430316409d1d6e24f98f5232e5d89b9c361f7525a0'; export type LaunchData = { app_id: string; auth_date: number; start_param?: string; user: { id: number; language_code: string; [field: string]: unknown } | null; [field: string]: unknown; }; const encoder = new TextEncoder(); function read(initData: string): LaunchData { const fields = Object.fromEntries(new URLSearchParams(initData)); return { ...fields, user: fields.user ? JSON.parse(fields.user) : null, auth_date: Number(fields.auth_date) } as LaunchData; } /** Every field but the ones left out, sorted by name, as "name=value" lines. */ function checkString(initData: string, leaveOut: string[]): string { return [...new URLSearchParams(initData)] .filter(([name]) => !leaveOut.includes(name)) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([name, value]) => `${name}=${value}`) .join('\n'); } function checkAge(data: LaunchData, maxAgeSeconds: number) { if (maxAgeSeconds && Date.now() / 1000 - data.auth_date > maxAgeSeconds) throw new Error('The launch data is too old.'); } const hex = (bytes: ArrayBuffer) => Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, '0')).join(''); const fromHex = (text: string) => Uint8Array.from(text.match(/../g) ?? [], (h) => parseInt(h, 16)); const fromBase64url = (text: string) => Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), (c) => c.charCodeAt(0)); async function hmac(key: BufferSource, data: string): Promise { const imported = await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); return crypto.subtle.sign('HMAC', imported, encoder.encode(data)); } export async function checkHash(initData: string, appSecret: string, { maxAgeSeconds = 86400 } = {}): Promise { const hash = new URLSearchParams(initData).get('hash') ?? ''; const secretKey = await hmac(encoder.encode('WebAppData'), appSecret); const expected = hex(await hmac(secretKey, checkString(initData, ['hash']))); // Compared in full, whatever the first difference (no early exit to time). let difference = hash.length ^ expected.length; for (let i = 0; i < expected.length; i++) difference |= (hash.charCodeAt(i) || 0) ^ expected.charCodeAt(i); if (difference) throw new Error("The launch data was not signed with this app's secret."); const data = read(initData); checkAge(data, maxAgeSeconds); return data; } export async function checkSignature(initData: string, appId: string, { maxAgeSeconds = 86400, publicKey = GARBO_PUBLIC_KEY } = {}): Promise { const params = new URLSearchParams(initData); if (params.get('app_id') !== appId) throw new Error('The launch data is for another app.'); const key = await crypto.subtle.importKey('raw', fromHex(publicKey), { name: 'Ed25519' }, false, ['verify']); const signed = encoder.encode(`${appId}:WebAppData\n${checkString(initData, ['hash', 'signature'])}`); if (!(await crypto.subtle.verify('Ed25519', key, fromBase64url(params.get('signature') ?? ''), signed))) { throw new Error('The launch data was not signed by Garbo.'); } const data = read(initData); checkAge(data, maxAgeSeconds); return data; }