"use node"; import crypto from "crypto"; import type { ActionCtx } from "./_generated/server"; import { internal } from "./_generated/api"; import type { Id } from "./_generated/dataModel"; const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; /** * Detached Ed25519 check: caller must sign `canonicalMessage` with the * Ed25519 private key that pairs with the userProfile's stored * `publicSigningKey`. Matches `voice.getToken` and * `recovery.resetPasswordAction` — same window, same SPKI/PEM encoding, * same hex signature format — so client-side signing code looks * identical across every sensitive call. * * Throws on failure. Sensitive mutations that previously trusted a raw * `userId` arg should now route through a "use node" action that calls * this helper before running the internal mutation. */ export async function requireAuth( ctx: ActionCtx, userId: Id<"userProfiles">, timestamp: number, signature: string, canonicalMessage: string, ): Promise { if ( !Number.isFinite(timestamp) || Math.abs(Date.now() - timestamp) > MAX_CLOCK_SKEW_MS ) { throw new Error("Request expired. Please try again."); } const user = await ctx.runQuery(internal.auth.getUserForVoiceToken, { userId, }); if (!user) { throw new Error("User not found"); } let isValid = false; try { const publicKeyObj = crypto.createPublicKey({ key: user.publicSigningKey, format: "pem", type: "spki", }); isValid = crypto.verify( null, Buffer.from(canonicalMessage), publicKeyObj, Buffer.from(signature, "hex"), ); } catch { throw new Error("Signature verification failed"); } if (!isValid) { throw new Error("Invalid signature"); } }