All checks were successful
Build and Release / build-and-release (push) Successful in 20m36s
64 lines
1.7 KiB
TypeScript
64 lines
1.7 KiB
TypeScript
"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<void> {
|
|
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");
|
|
}
|
|
}
|