// Checking Garbo's launch data on your server, in Node.js 18 or newer (no packages). // https://developers.garbo.ph/#check // // Your page sends Garbo.WebApp.initData to your server; never trust initDataUnsafe there. // Check it one of two ways (or both): // - 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. import { createHmac, createPublicKey, timingSafeEqual, verify } from 'node:crypto'; /** Garbo's public key for the launch data's signature (Ed25519, hex). */ export const GARBO_PUBLIC_KEY = 'a6b7e2bcbd35fa46f8040e430316409d1d6e24f98f5232e5d89b9c361f7525a0'; /** The launch data's fields (URL-decoded), with `user` read from its JSON. */ function read(initData) { const fields = Object.fromEntries(new URLSearchParams(initData)); return { ...fields, user: fields.user ? JSON.parse(fields.user) : null, auth_date: Number(fields.auth_date) }; } /** Every field but the ones left out, sorted by name, as "name=value" lines. */ function checkString(initData, leaveOut) { 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'); } /** Old launch data is refused: by default after a day (pass maxAgeSeconds: 0 to skip). */ function checkAge(data, maxAgeSeconds) { if (maxAgeSeconds && Date.now() / 1000 - data.auth_date > maxAgeSeconds) throw new Error('The launch data is too old.'); } export function checkHash(initData, appSecret, { maxAgeSeconds = 86400 } = {}) { const hash = new URLSearchParams(initData).get('hash') ?? ''; const secretKey = createHmac('sha256', 'WebAppData').update(appSecret).digest(); const expected = createHmac('sha256', secretKey).update(checkString(initData, ['hash'])).digest('hex'); if (hash.length !== expected.length || !timingSafeEqual(Buffer.from(hash), Buffer.from(expected))) { throw new Error('The launch data was not signed with this app\'s secret.'); } const data = read(initData); checkAge(data, maxAgeSeconds); return data; } export function checkSignature(initData, appId, { maxAgeSeconds = 86400, publicKey = GARBO_PUBLIC_KEY } = {}) { const params = new URLSearchParams(initData); if (params.get('app_id') !== appId) throw new Error('The launch data is for another app.'); const signature = Buffer.from(params.get('signature') ?? '', 'base64url'); // An Ed25519 public key in the form Node reads (its fixed 12-byte header, then the key). const key = createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), Buffer.from(publicKey, 'hex')]), format: 'der', type: 'spki' }); const signed = Buffer.from(`${appId}:WebAppData\n${checkString(initData, ['hash', 'signature'])}`); if (!verify(null, signed, key, signature)) throw new Error('The launch data was not signed by Garbo.'); const data = read(initData); checkAge(data, maxAgeSeconds); return data; }