1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual.
2import * as NodeCrypto from "node:crypto";
3
4/**
5 * The desktop app and the backends it launches share one secret, delivered
6 * over the backend's bootstrap channel and never sent to the renderer. Both
7 * sides derive the bootstrap token for a time window from it, so the token the
8 * renderer holds rotates every window without the desktop having to reach a
9 * running backend. A backend accepts the previous, current and next window's
10 * token: the previous one so a token works for between one and two windows,
11 * and the next one because a WSL backend's clock can trail the Windows clock
12 * that derived the token, which would otherwise reject every fresh token for
13 * a moment after each boundary. A token is still dead two windows after it
14 * was issued.
15 */
16export const DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS = 12 * 60 * 60 * 1000;
17
18function windowIndex(nowMs: number): number {
19 return Math.floor(nowMs / DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS);
20}
21
22function deriveToken(secret: string, window: number): string {
23 return NodeCrypto.createHmac("sha256", secret)
24 .update(`t3-desktop-bootstrap:${window}`)
25 .digest("hex");
26}
27
28/** The token the desktop hands out at `nowMs`. */
29export function currentDesktopBootstrapToken(secret: string, nowMs: number): string {
30 return deriveToken(secret, windowIndex(nowMs));
31}
32
33/** Whether `token` is the previous, current or next window's token at `nowMs`. */
34export function isValidDesktopBootstrapToken(
35 secret: string,
36 token: string,
37 nowMs: number,
38): boolean {
39 const presented = Buffer.from(token);
40 const current = windowIndex(nowMs);
41 return [current - 1, current, current + 1].some((window) => {
42 const expected = Buffer.from(deriveToken(secret, window));
43 return expected.length === presented.length && NodeCrypto.timingSafeEqual(expected, presented);
44 });
45}