Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/__tests__/mocks/encryption.ts
Original file line number Diff line number Diff line change
@@ -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
}));
}
9 changes: 5 additions & 4 deletions apps/web/src/pages/api/auth-hackatime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand All @@ -243,8 +244,8 @@ export default async function handler(
else {
const updateData: Parameters<typeof database.user.update>[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) {
Expand Down
52 changes: 51 additions & 1 deletion apps/web/src/server/encryption.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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;
}
}
5 changes: 4 additions & 1 deletion apps/web/src/server/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/server/routers/api/hackatime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/server/routers/api/timelapse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
}

Expand Down