import { createHmac, timingSafeEqual } from "node:crypto"; /** * Slack request signature verification. * * This endpoint is the one route in the app that is NOT behind Traefik's OAuth * chain — it has to be, because Slack posts to it as a machine with no browser * session. The signature IS the authentication, so it is not optional and it * fails closed: an unset signing secret rejects everything rather than waving * requests through, which is the failure mode that would quietly expose split * writes to the open internet. * * The v0 scheme signs `v0::`. It must be the RAW body — * re-serialising the parsed form changes the bytes and every signature fails. */ export function verifySlackSignature( rawBody: string, timestamp: string | null, signature: string | null ): boolean { const secret = process.env.SLACK_SIGNING_SECRET; if (!secret || !timestamp || !signature) return false; // Replay window. Slack recommends 5 minutes; a captured request is otherwise // valid forever, and these actions move money between people. const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(age) || age > 300) return false; const expected = "v0=" + createHmac("sha256", secret) .update(`v0:${timestamp}:${rawBody}`) .digest("hex"); const a = Buffer.from(expected); const b = Buffer.from(signature); // timingSafeEqual throws on length mismatch, which is itself a leak of one // bit; check length first and return the same false either way. return a.length === b.length && timingSafeEqual(a, b); } /** * Which participant pressed the button. * * SLACK_USER_MAP is `:` pairs, comma separated. * An unknown Slack user is rejected rather than defaulted to the owner: in a * two-person household a wrong attribution is not a rounding error, it is the * other person's opinion recorded under your name. */ /** The reverse: which Slack user is this participant, for DMing them. */ export function slackUserForParticipant(participantId: number): string | null { const map = process.env.SLACK_USER_MAP ?? ""; for (const pair of map.split(",")) { const [slack, participant] = pair.split(":").map((s) => s.trim()); if (slack && Number(participant) === participantId) return slack; } return null; } export function participantForSlackUser(slackUserId: string): number | null { const map = process.env.SLACK_USER_MAP ?? ""; for (const pair of map.split(",")) { const [slack, participant] = pair.split(":").map((s) => s.trim()); if (slack && slack === slackUserId && participant) { const id = Number(participant); if (Number.isInteger(id)) return id; } } return null; }