diff options
Diffstat (limited to 'server/shared.ts')
| -rw-r--r-- | server/shared.ts | 209 |
1 files changed, 209 insertions, 0 deletions
diff --git a/server/shared.ts b/server/shared.ts new file mode 100644 index 0000000..b3fad21 --- /dev/null +++ b/server/shared.ts @@ -0,0 +1,209 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import { songs } from "./data/songs"; + +function getSalt(): string { + return process.env.VITE_HEARDLE_SALT ?? "changeme"; +} + +function getSigningSecret(): string { + return process.env.HEARDLE_SIGNING_SECRET ?? getSalt(); +} + +export type Song = (typeof songs)[number]; + +export type GuessResult = "Correct" | "PartiallyCorrect" | "Incorrect" | "Skipped"; + +export type GuessEntry = { + song?: { + artist: string; + name: string; + }; + state: GuessResult; +}; + +export type SignedState = { + date: string; + currentTry: number; + didGuess: boolean; + guesses: GuessEntry[]; +}; + +export function getObfuscationKey(date = getUtcDate()): Buffer { + return Buffer.from(getSalt() + date); +} + +export function xorBuffer(data: Buffer, key: Buffer): Buffer { + const output = Buffer.alloc(data.length); + for (let i = 0; i < data.length; i++) { + output[i] = data[i] ^ key[i % key.length]; + } + return output; +} + +export function getUtcDate(): string { + return new Date().toISOString().slice(0, 10); +} + +function hashString(str: string): number { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash * 31 + str.charCodeAt(i)) >>> 0; + } + return hash; +} + +export function getDailySong(date: string): Song { + const seed = hashString(date); + const index = seed % songs.length; + return songs[index]; +} + +function signValue(prefix: string, value: string): string { + return createHmac("sha256", getSigningSecret()) + .update(`${prefix}:${value}`) + .digest("hex"); +} + +function safeEqualHex(a: string, b: string): boolean { + try { + const left = Buffer.from(a, "hex"); + const right = Buffer.from(b, "hex"); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); + } catch { + return false; + } +} + +export function buildSessionToken(date: string): string { + const payload = Buffer.from(JSON.stringify({ date }), "utf8").toString("base64url"); + const sig = signValue("session", payload); + return `${payload}.${sig}`; +} + +export function verifySessionToken(token: unknown, expectedDate: string): boolean { + if (typeof token !== "string") return false; + const [payload, sig] = token.split("."); + if (!payload || !sig) return false; + + const expectedSig = signValue("session", payload); + if (!safeEqualHex(sig, expectedSig)) return false; + + try { + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + date?: unknown; + }; + + return typeof decoded.date === "string" && decoded.date === expectedDate; + } catch { + return false; + } +} + +function normalizeGuessEntry(entry: unknown): GuessEntry | null { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; + + const maybeState = (entry as { state?: unknown }).state; + if ( + maybeState !== "Correct" && + maybeState !== "PartiallyCorrect" && + maybeState !== "Incorrect" && + maybeState !== "Skipped" + ) { + return null; + } + + const maybeSong = (entry as { song?: unknown }).song; + + if (maybeState === "Skipped") { + return { state: "Skipped" }; + } + + if (!maybeSong || typeof maybeSong !== "object" || Array.isArray(maybeSong)) { + return null; + } + + const artist = (maybeSong as { artist?: unknown }).artist; + const name = (maybeSong as { name?: unknown }).name; + + if (typeof artist !== "string" || typeof name !== "string") { + return null; + } + + return { + song: { artist, name }, + state: maybeState, + }; +} + +export function normalizeSignedState(input: unknown): SignedState | null { + if (!input || typeof input !== "object" || Array.isArray(input)) return null; + + const date = (input as { date?: unknown }).date; + const currentTryRaw = (input as { currentTry?: unknown }).currentTry; + const didGuessRaw = (input as { didGuess?: unknown }).didGuess; + const guessesRaw = (input as { guesses?: unknown }).guesses; + + if (typeof date !== "string") return null; + if (!Array.isArray(guessesRaw)) return null; + if (typeof currentTryRaw !== "number" || !Number.isFinite(currentTryRaw)) return null; + + const currentTry = Math.floor(currentTryRaw); + if (currentTry < 0 || currentTry > 6) return null; + + const guesses: GuessEntry[] = []; + for (const entry of guessesRaw) { + const normalized = normalizeGuessEntry(entry); + if (!normalized) return null; + guesses.push(normalized); + } + + if (guesses.length > 6) return null; + if (guesses.length !== currentTry) return null; + + const didGuess = !!didGuessRaw; + if (didGuess && !guesses.some((guess) => guess.state === "Correct")) { + return null; + } + + return { + date, + currentTry, + didGuess, + guesses, + }; +} + +function canonicalState(state: SignedState): string { + return JSON.stringify({ + date: state.date, + currentTry: state.currentTry, + didGuess: state.didGuess, + guesses: state.guesses.map((guess) => ({ + state: guess.state, + song: guess.song + ? { + artist: guess.song.artist, + name: guess.song.name, + } + : null, + })), + }); +} + +export function signState(state: SignedState): string { + return signValue("state", canonicalState(state)); +} + +export function verifyStateSignature(state: SignedState, sig: unknown): boolean { + if (typeof sig !== "string") return false; + const expectedSig = signState(state); + return safeEqualHex(sig, expectedSig); +} + +export function obfuscateSong(song: Song, date?: string): string { + const obfuscationKey = getObfuscationKey(date); + const songJson = JSON.stringify(song); + const obfuscatedData = xorBuffer(Buffer.from(songJson, "utf8"), obfuscationKey); + return obfuscatedData.toString("hex"); +} |
