import { createHash, timingSafeEqual } from 'node:crypto'; import { str } from './callback-fields'; /** * Checking PayMe's `payme_signature`. Partners only: the formula uses partner * credentials, which a direct seller does not hold. * * WHAT A VALID SIGNATURE PROVES: PayMe once produced this pair of ids. Nothing * more. The hash covers only the credentials and two ids, so it says nothing * about the status, the amount or the notification type, and a captured callback * can be replayed with those changed and still pass. It is NOT proof of payment: * a valid signature means "worth checking", and you then read the sale back from * PayMe (sale-query-verification.ts) and apply PayMe's answer. * * The signature is an md5 of four values concatenated with no separator: * * SALE: md5(client_key + client_secret + payme_transaction_id + payme_sale_id) * SUBSCRIPTION * RETURN URL ONLY: md5(client_key + client_secret + transaction_id + sub_payme_id) * * where * client_key = your PayMe partner key (the same value you send as * `payme_client_key`; PayMe stores it as merchant_key) * client_secret = your partner secret (merchant_password). Keep it on your * server; it exists only to compute this hash. * * Three subtleties that cost people hours: * * 1. Subscription CALLBACKS are not signed and carry no `transaction_id`, so * there is nothing to check; see partner-callbacks.ts. The subscription * formula applies only to the query string of the buyer's redirect to * `sub_return_url`, where `transaction_id` is PayMe's TRANSACTION guid. * * 2. On sale callbacks the identically-named `transaction_id` is YOUR order id * and is NOT part of the hash — the sale hash uses `payme_transaction_id`. * Mixing them up produces a mismatch that looks like a wrong secret. * * 3. `payme_signature` is null on a sale callback when the sale has no * transaction yet. PayMe leaves empty fields out of callbacks, so the field * is then absent. A missing signature is not a forgery, but it proves nothing: * treat it as UNSIGNED and do not let it move money. * * md5 is PayMe's choice, not ours. It is used here only to compare against a * value PayMe generated with the same algorithm; we still compare in constant * time so the endpoint does not leak the expected digest byte by byte. */ export type SignatureStatus = /** Signature present and correct. Worth checking; still not proof of payment. */ | 'valid' /** Signature present and wrong — reject the payload. */ | 'invalid' /** Payload carries no signature. */ | 'unsigned' /** No client secret configured, so verification could not be attempted. */ | 'unverifiable'; export interface SignatureCheck { status: SignatureStatus; /** True only for 'valid'. Convenience for guard clauses. */ ok: boolean; /** Human-readable reason, for your callback log. */ reason: string; } /** md5 of the four concatenated values, lowercase hex — PayMe's own format. */ export function computePayMeSignature( clientKey: string, clientSecret: string, paymeTransactionId: string, entityId: string, ): string { return createHash('md5') .update(`${clientKey}${clientSecret}${paymeTransactionId}${entityId}`) .digest('hex'); } /** Constant-time comparison of two hex digests of equal length. */ function digestsMatch(a: string, b: string): boolean { const left = Buffer.from(a.toLowerCase(), 'utf8'); const right = Buffer.from(b.toLowerCase(), 'utf8'); if (left.length !== right.length) return false; return timingSafeEqual(left, right); } interface VerifyArgs { clientKey: string; /** Empty string / undefined => 'unverifiable'. */ clientSecret?: string | null; /** The signature as received. */ received?: string | null; /** PayMe's transaction guid (sale: payme_transaction_id, subscription return URL: transaction_id). */ paymeTransactionId?: string | null; /** The sale id (payme_sale_id) or subscription id (sub_payme_id). */ entityId?: string | null; } function verify({ clientKey, clientSecret, received, paymeTransactionId, entityId, }: VerifyArgs): SignatureCheck { if (!received) { return { status: 'unsigned', ok: false, reason: 'No payme_signature. PayMe sends none when the sale has no transaction yet. Do not treat this payload as proof of payment.', }; } if (!clientSecret) { return { status: 'unverifiable', ok: false, reason: 'No partner secret configured, so the signature could not be checked. A production deployment must never accept an unverified callback.', }; } if (!paymeTransactionId || !entityId) { return { status: 'invalid', ok: false, reason: `Signature present but the payload is missing the ids it is computed from (transaction=${paymeTransactionId ?? 'null'}, entity=${entityId ?? 'null'}).`, }; } const expected = computePayMeSignature( clientKey, clientSecret, paymeTransactionId, entityId, ); return digestsMatch(expected, received) ? { status: 'valid', ok: true, reason: 'Signature matches. Not proof of payment: confirm with PayMe before acting.', } : { status: 'invalid', ok: false, reason: 'Signature mismatch. Either the payload was not sent by PayMe, or the configured partner key and secret do not match the partner account that owns this sale.', }; } /** * Check a sale's signature: on a sale callback (sale-complete, sale-authorized, * refund, ...) or on the query string of the buyer's redirect to sale_return_url. * Hash input: client_key + client_secret + payme_transaction_id + payme_sale_id. */ export function verifySaleSignature( body: Record, clientKey: string, clientSecret?: string | null, ): SignatureCheck { return verify({ clientKey, clientSecret, received: str(body.payme_signature), paymeTransactionId: str(body.payme_transaction_id), entityId: str(body.payme_sale_id), }); } /** * Check the signature on the query string of the buyer's redirect to * sub_return_url. NOT for callbacks: subscription callbacks are unsigned. * Hash input: client_key + client_secret + transaction_id + sub_payme_id. * * Careful: `transaction_id` here is PayMe's transaction guid, unlike on sale * callbacks where the same key holds YOUR order id. */ export function verifySubscriptionReturnUrlSignature( query: Record, clientKey: string, clientSecret?: string | null, ): SignatureCheck { return verify({ clientKey, clientSecret, received: str(query.payme_signature), paymeTransactionId: str(query.transaction_id), entityId: str(query.sub_payme_id), }); }