| 1 | // @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createHmac or timingSafeEqual. |
| 2 | import * 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 | */ |
| 16 | export const DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS = 12 * 60 * 60 * 1000; |
| 17 | |
| 18 | function windowIndex(nowMs: number): number { |
| 19 | return Math.floor(nowMs / DESKTOP_BOOTSTRAP_TOKEN_WINDOW_MS); |
| 20 | } |
| 21 | |
| 22 | function 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`. */ |
| 29 | export 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`. */ |
| 34 | export 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 | } |