diff --git a/apps/web/.env.example b/apps/web/.env.example index ae124a52..658258ff 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -41,6 +41,9 @@ JWT_SECRET=deadc0defc25c6d4d250104c398c35b35c5e202da4ec568168598b1f952e499a UPLOAD_TOKEN_PRIVATE_KEY=deadc0dedde2e815f42c9c678b2ae26957a85315a16c702ff5c136ea66570aff UPLOAD_TOKEN_IV=deadc0de7eeeee17273643d4707be04f +# 32-byte (64 hex chars) key for encrypting Hackatime access tokens at rest (AES-256-GCM) +HACKATIME_TOKEN_ENCRYPTION_KEY=8ba6a6c12a32c3b041d4332c541007fc7e32a849e2748286bebe2194c5c67b19 + # This is related to Sentry - the error monitoring solution we use. In most cases, during development, you won't have to specify this. NEXT_PUBLIC_SENTRY_DSN= SENTRY_ORG= diff --git a/apps/web/src/__tests__/mocks/encryption.ts b/apps/web/src/__tests__/mocks/encryption.ts index 77434309..bbd28ae0 100644 --- a/apps/web/src/__tests__/mocks/encryption.ts +++ b/apps/web/src/__tests__/mocks/encryption.ts @@ -1,9 +1,11 @@ import { vi } from "vitest"; export const mockDecryptVideo = vi.fn().mockReturnValue(Buffer.from([1, 2, 3])); +export const mockDecryptToken = vi.fn().mockImplementation((token: string | null) => token); export function setupEncryptionMock(): void { vi.mock("@/server/encryption", () => ({ - decryptVideo: mockDecryptVideo + decryptVideo: mockDecryptVideo, + decryptToken: mockDecryptToken })); } diff --git a/apps/web/src/pages/api/auth-hackatime.ts b/apps/web/src/pages/api/auth-hackatime.ts index 6147ca57..598b7ef4 100644 --- a/apps/web/src/pages/api/auth-hackatime.ts +++ b/apps/web/src/pages/api/auth-hackatime.ts @@ -6,6 +6,7 @@ import { env } from "@/server/env"; import { logError, logNextRequest } from "@/server/serverCommon"; import { database } from "@/server/db"; import { MAX_HANDLE_LENGTH, MIN_HANDLE_LENGTH } from "@/shared/constants"; +import { encryptToken } from "@/server/encryption"; // GET /api/auth-hackatime // Meant to be used as a callback URL - the user will be redirected to this API endpoint when @@ -227,8 +228,8 @@ export default async function handler( data: { email: primaryEmail, hackatimeId: hackatimeUser.id.toString(), - hackatimeAccessToken: tokenData.access_token, - hackatimeRefreshToken: tokenData.refresh_token || null, + hackatimeAccessToken: encryptToken(tokenData.access_token), + hackatimeRefreshToken: tokenData.refresh_token ? encryptToken(tokenData.refresh_token) : null, slackId: hackatimeUser.slack_id || null, handle: handle, displayName: primaryEmail.split("@")[0], @@ -243,8 +244,8 @@ export default async function handler( else { const updateData: Parameters[0]["data"] = { hackatimeId, - hackatimeAccessToken: tokenData.access_token, - hackatimeRefreshToken: tokenData.refresh_token || null, + hackatimeAccessToken: encryptToken(tokenData.access_token), + hackatimeRefreshToken: tokenData.refresh_token ? encryptToken(tokenData.refresh_token) : null, }; if (hackatimeUser.slack_id) { diff --git a/apps/web/src/server/encryption.ts b/apps/web/src/server/encryption.ts index 2ed47d84..007ab818 100644 --- a/apps/web/src/server/encryption.ts +++ b/apps/web/src/server/encryption.ts @@ -1,6 +1,7 @@ import "@/server/allow-only-server"; import crypto from "crypto"; +import { env } from "./env"; function deriveSalts(timelapseId: string): { keySalt: Buffer; ivSalt: Buffer } { const keySalt = crypto.createHmac('sha256', 'timelapse-key-salt').update(timelapseId).digest(); @@ -67,6 +68,55 @@ export function decryptData(encryptedData: Buffer | Uint8Array, key: string, iv: decipher.update(inputBuffer), decipher.final() ]); - + return decryptedBuffer; } + +// encrypt hackatime tokens using aes-256-gcm +// returns base64 encoded string (iv:authTag:encrypted) +export function encryptToken(plaintext: string): string { + const key = Buffer.from(env.HACKATIME_TOKEN_ENCRYPTION_KEY, "hex"); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, "utf8"), + cipher.final() + ]); + const authTag = cipher.getAuthTag(); + + // format: iv:authTag:encrypted (all base64) + return `${iv.toString("base64")}:${authTag.toString("base64")}:${encrypted.toString("base64")}`; +} + +// decrypt hackatime tokens +export function decryptToken(encrypted: string | null): string | null { + if (!encrypted) return null; + + // check if already plaintext (old tokens not yet migrated) + if (!encrypted.includes(":")) { + return encrypted; + } + + const key = Buffer.from(env.HACKATIME_TOKEN_ENCRYPTION_KEY, "hex"); + + try { + const [ivB64, authTagB64, encryptedB64] = encrypted.split(":"); + const iv = Buffer.from(ivB64, "base64"); + const authTag = Buffer.from(authTagB64, "base64"); + const ciphertext = Buffer.from(encryptedB64, "base64"); + + const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(ciphertext), + decipher.final() + ]); + + return decrypted.toString("utf8"); + } catch { + // if decryption fails, assume it's plaintext + return encrypted; + } +} diff --git a/apps/web/src/server/env.ts b/apps/web/src/server/env.ts index 2ec3dccf..b1cfe5d4 100644 --- a/apps/web/src/server/env.ts +++ b/apps/web/src/server/env.ts @@ -107,7 +107,10 @@ export const env = { /** * The Slack API URL. */ - get SLACK_API_URL() { return optional("SLACK_API_URL") || "https://slack.com/api" } + get SLACK_API_URL() { return optional("SLACK_API_URL") || "https://slack.com/api" }, + + // encryption key for hackatime tokens (generate with: openssl rand -hex 32) + get HACKATIME_TOKEN_ENCRYPTION_KEY() { return required("HACKATIME_TOKEN_ENCRYPTION_KEY") } }; function required(name: string) { diff --git a/apps/web/src/server/routers/api/hackatime.ts b/apps/web/src/server/routers/api/hackatime.ts index c87f8031..e621c1a5 100644 --- a/apps/web/src/server/routers/api/hackatime.ts +++ b/apps/web/src/server/routers/api/hackatime.ts @@ -8,6 +8,7 @@ import { router, protectedProcedure } from "@/server/trpc"; import { logError, logRequest } from "@/server/serverCommon"; import { database } from "@/server/db"; import { HackatimeOAuthApi } from "@/server/hackatime"; +import { decryptToken } from "@/server/encryption"; /** * Represents a Hackatime project of a given user. @@ -40,7 +41,11 @@ export default router({ if (!dbUser.hackatimeId || !dbUser.hackatimeAccessToken) return apiErr("ERROR", "You must have a linked Hackatime account!"); - const oauthApi = new HackatimeOAuthApi(dbUser.hackatimeAccessToken); + const accessToken = decryptToken(dbUser.hackatimeAccessToken); + if (!accessToken) + return apiErr("ERROR", "You must have a linked Hackatime account!"); + + const oauthApi = new HackatimeOAuthApi(accessToken); try { const projects = await oauthApi.getProjects(); diff --git a/apps/web/src/server/routers/api/timelapse.ts b/apps/web/src/server/routers/api/timelapse.ts index 3d17e373..86d5cbfa 100644 --- a/apps/web/src/server/routers/api/timelapse.ts +++ b/apps/web/src/server/routers/api/timelapse.ts @@ -9,7 +9,7 @@ import { MAX_VIDEO_FRAME_COUNT, MAX_VIDEO_UPLOAD_SIZE, MAX_THUMBNAIL_UPLOAD_SIZE import { createUploadToken, consumeUploadTokens } from "@/server/services/uploadTokens"; import { procedure, router, protectedProcedure } from "@/server/trpc"; -import { decryptVideo } from "@/server/encryption"; +import { decryptVideo, decryptToken } from "@/server/encryption"; import { env } from "@/server/env"; import { HackatimeOAuthApi, HackatimeUserApi, WakaTimeHeartbeat } from "@/server/hackatime"; import { logError, logInfo, logRequest } from "@/server/serverCommon"; @@ -777,13 +777,17 @@ export default router({ if (!timelapse.owner.hackatimeId || !timelapse.owner.hackatimeAccessToken) return apiErr("ERROR", "You must have a linked Hackatime account to sync with Hackatime!"); + const accessToken = decryptToken(timelapse.owner.hackatimeAccessToken); + if (!accessToken) + return apiErr("ERROR", "You must have a linked Hackatime account to sync with Hackatime!"); + let userApiKey: string | null; if (process.env.NODE_ENV !== "production" && env.DEV_HACKATIME_FALLBACK_KEY) { userApiKey = env.DEV_HACKATIME_FALLBACK_KEY; } else { - const oauthApi = new HackatimeOAuthApi(timelapse.owner.hackatimeAccessToken); + const oauthApi = new HackatimeOAuthApi(accessToken); userApiKey = await oauthApi.apiKey(); }