From fda65294197b4b26400287f9f4b2b62b68b6334d Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Sun, 15 Jun 2025 12:37:31 -0700 Subject: [PATCH] feat(open-swe): encrypt GitHub tokens in proxy route to prevent exposure in LangGraph runs (#160) * Apply patch * Apply patch * Apply patch * Apply patch * Apply patch * cr * docs --- README.md | 16 ++- apps/open-swe/.env.example | 4 + apps/open-swe/src/security/auth.ts | 32 +++++- apps/open-swe/src/utils/github-tokens.ts | 38 ++++++- apps/web/.env.example | 6 ++ apps/web/src/app/api/[..._path]/route.ts | 61 ++++++++++- packages/shared/src/constants.ts | 1 + packages/shared/src/crypto.ts | 132 +++++++++++++++++++++++ packages/shared/src/open-swe/types.ts | 20 +++- 9 files changed, 294 insertions(+), 16 deletions(-) create mode 100644 packages/shared/src/crypto.ts diff --git a/README.md b/README.md index 19ab2e1e..91f55312 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ cp ./apps/open-swe/.env.example ./apps/open-swe/.env cp ./apps/web/.env.example ./apps/web/.env ``` -The agent `.env` file should contain the following variables: +The open-swe `.env` file should contain the following variables: ```bash # ------------------LangSmith tracing------------------ @@ -45,12 +45,17 @@ GOOGLE_API_KEY="" # Daytona API key for accessing and modifying the code in the cloud sandbox. DAYTONA_API_KEY="" + +# Encryption key for GitHub tokens (32-byte hex string for AES-256) +# Should be the same value as the one used in the web app. +# Can be generated via: `openssl rand -hex 32` +GITHUB_TOKEN_ENCRYPTION_KEY="" ``` And the web `.env` file should contain the following variables: ```bash -# Change to production URLs when deployed +# Change both to production URLs when deployed NEXT_PUBLIC_API_URL="http://localhost:3000/api" LANGGRAPH_API_URL="http://localhost:2024" NEXT_PUBLIC_ASSISTANT_ID="open-swe" @@ -63,8 +68,15 @@ GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback" GITHUB_APP_NAME="open-swe-dev" GITHUB_APP_ID="" GITHUB_APP_PRIVATE_KEY="" + +# Encryption key for GitHub tokens (32-byte hex string for AES-256) +# Should be the same value as the one used in the open-swe app. +# Can be generated via: `openssl rand -hex 32` +GITHUB_TOKEN_ENCRYPTION_KEY="" ``` +**REMINDER**: The `GITHUB_TOKEN_ENCRYPTION_KEY` environment variable must be the same in both the web and open-swe apps. + To get the GitHub App secrets, first create a new GitHub app (note: this is not the same as the OAuth app) in [the developer settings](https://github.com/settings/apps/new). Give the app a name and description. diff --git a/apps/open-swe/.env.example b/apps/open-swe/.env.example index 5bf4425c..4805a9d9 100644 --- a/apps/open-swe/.env.example +++ b/apps/open-swe/.env.example @@ -11,3 +11,7 @@ GOOGLE_API_KEY="" # Daytona API key for accessing and modifying the code in the cloud sandbox. DAYTONA_API_KEY="" + +# Encryption key for GitHub tokens (32-byte hex string for AES-256) +# Should be the same value as the one used in the web app. +GITHUB_TOKEN_ENCRYPTION_KEY="" diff --git a/apps/open-swe/src/security/auth.ts b/apps/open-swe/src/security/auth.ts index ece32fe2..ad37085d 100644 --- a/apps/open-swe/src/security/auth.ts +++ b/apps/open-swe/src/security/auth.ts @@ -1,6 +1,10 @@ import { Auth, HTTPException } from "@langchain/langgraph-sdk/auth"; import { verifyGithubUser, GithubUser } from "./github-auth.js"; -import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; +import { + GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_TOKEN_COOKIE, +} from "@open-swe/shared/constants"; +import { decryptGitHubToken } from "@open-swe/shared/crypto"; const STUDIO_USER_ID = "langgraph-studio-user"; @@ -38,18 +42,38 @@ export const auth = new Auth() display_name: "CORS Preflight", }; } + const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY; + if (!encryptionKey) { + throw new Error( + "Missing GITHUB_TOKEN_ENCRYPTION_KEY environment variable.", + ); + } + // Parse Authorization header - const accessToken = request.headers.get(GITHUB_TOKEN_COOKIE); - if (!accessToken) { + const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE); + if (!encryptedAccessToken) { throw new HTTPException(401, { message: "GitHub access token header missing", }); } + // We don't do anything with this token right now, but still confirm it + // exists as it will cause issues later on if it's not present. + const encryptedInstallationToken = request.headers.get( + GITHUB_INSTALLATION_TOKEN_COOKIE, + ); + if (!encryptedInstallationToken) { + throw new HTTPException(401, { + message: "GitHub installation token header missing", + }); + } // Validate GitHub access token let user: GithubUser | undefined; try { - user = await verifyGithubUser(accessToken); + // Ensure we decrypt the token before passing to the verification function. + user = await verifyGithubUser( + decryptGitHubToken(encryptedAccessToken, encryptionKey), + ); if (!user) { throw new HTTPException(401, { message: diff --git a/apps/open-swe/src/utils/github-tokens.ts b/apps/open-swe/src/utils/github-tokens.ts index 31e6c0f6..a914d582 100644 --- a/apps/open-swe/src/utils/github-tokens.ts +++ b/apps/open-swe/src/utils/github-tokens.ts @@ -1,15 +1,43 @@ -import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; +import { + GITHUB_TOKEN_COOKIE, + GITHUB_INSTALLATION_TOKEN_COOKIE, +} from "@open-swe/shared/constants"; import { GraphConfig } from "@open-swe/shared/open-swe/types"; +import { decryptGitHubToken } from "@open-swe/shared/crypto"; export function getGitHubTokensFromConfig(config: GraphConfig): { githubAccessToken: string; + githubInstallationToken: string; } { if (!config.configurable) { throw new Error("No configurable object found in graph config."); } - const githubAccessToken = config.configurable[GITHUB_TOKEN_COOKIE]; - if (!githubAccessToken) { - throw new Error("Missing required x-github-access-token in configuration."); + const encryptedGitHubToken = config.configurable[GITHUB_TOKEN_COOKIE]; + const encryptedInstallationToken = + config.configurable[GITHUB_INSTALLATION_TOKEN_COOKIE]; + if (!encryptedGitHubToken || !encryptedInstallationToken) { + throw new Error( + "Missing required x-github-access-token or x-github-installation-token in configuration.", + ); } - return { githubAccessToken }; + + // Get the encryption key from environment variables + const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY; + if (!encryptionKey) { + throw new Error( + "Missing GITHUB_TOKEN_ENCRYPTION_KEY environment variable.", + ); + } + + // Decrypt the GitHub token + const githubAccessToken = decryptGitHubToken( + encryptedGitHubToken, + encryptionKey, + ); + const githubInstallationToken = decryptGitHubToken( + encryptedInstallationToken, + encryptionKey, + ); + + return { githubAccessToken, githubInstallationToken }; } diff --git a/apps/web/.env.example b/apps/web/.env.example index 8a496674..fee3516b 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,3 +1,4 @@ +# Change both to production URLs when deployed NEXT_PUBLIC_API_URL="http://localhost:3000/api" LANGGRAPH_API_URL="http://localhost:2024" NEXT_PUBLIC_ASSISTANT_ID="open-swe" @@ -10,3 +11,8 @@ GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback" GITHUB_APP_NAME="open-swe-dev" GITHUB_APP_ID="" GITHUB_APP_PRIVATE_KEY="" + +# Encryption key for GitHub tokens (32-byte hex string for AES-256) +# Should be the same value as the one used in the open-swe app. +# Can be generated via: `openssl rand -hex 32` +GITHUB_TOKEN_ENCRYPTION_KEY="" diff --git a/apps/web/src/app/api/[..._path]/route.ts b/apps/web/src/app/api/[..._path]/route.ts index 0a203fe7..a50a7fb9 100644 --- a/apps/web/src/app/api/[..._path]/route.ts +++ b/apps/web/src/app/api/[..._path]/route.ts @@ -1,5 +1,50 @@ import { initApiPassthrough } from "langgraph-nextjs-api-passthrough"; -import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants"; +import { + GITHUB_TOKEN_COOKIE, + GITHUB_INSTALLATION_TOKEN_COOKIE, +} from "@open-swe/shared/constants"; +import { encryptGitHubToken } from "@open-swe/shared/crypto"; +import { NextRequest } from "next/server"; +import { getInstallationToken } from "@/utils/github"; +import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth"; + +function getGitHubAccessTokenOrThrow( + req: NextRequest, + encryptionKey: string, +): string { + const token = req.cookies.get(GITHUB_TOKEN_COOKIE)?.value ?? ""; + + if (!token) { + throw new Error("No GitHub access token cookie found."); + } + + return encryptGitHubToken(token, encryptionKey); +} + +async function getGitHubInstallationTokenOrThrow( + req: NextRequest, + encryptionKey: string, +): Promise { + const installationIdCookie = req.cookies.get( + GITHUB_INSTALLATION_ID_COOKIE, + )?.value; + if (!installationIdCookie) { + throw new Error("No GitHub installation ID cookie found."); + } + + const appId = process.env.GITHUB_APP_ID; + const privateAppKey = process.env.GITHUB_APP_PRIVATE_KEY; + if (!appId || !privateAppKey) { + throw new Error("GitHub App ID or Private App Key is not configured."); + } + + const token = await getInstallationToken( + installationIdCookie, + appId, + privateAppKey, + ); + return encryptGitHubToken(token, encryptionKey); +} // This file acts as a proxy for requests to your LangGraph server. // Read the [Going to Production](https://github.com/langchain-ai/agent-chat-ui?tab=readme-ov-file#going-to-production) section for more information. @@ -9,10 +54,18 @@ export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } = apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024", runtime: "edge", // default disableWarningLog: true, - headers: (req) => { + headers: async (req) => { + const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY; + if (!encryptionKey) { + throw new Error( + "GITHUB_TOKEN_ENCRYPTION_KEY environment variable is required", + ); + } + return { - [GITHUB_TOKEN_COOKIE]: - req.cookies.get(GITHUB_TOKEN_COOKIE)?.value ?? "", + [GITHUB_TOKEN_COOKIE]: getGitHubAccessTokenOrThrow(req, encryptionKey), + [GITHUB_INSTALLATION_TOKEN_COOKIE]: + await getGitHubInstallationTokenOrThrow(req, encryptionKey), }; }, }); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 68cf6b4b..06483e34 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -6,3 +6,4 @@ export const PLAN_INTERRUPT_ACTION_TITLE = "Approve/Edit Plan"; // Prefix the access token with `x-` so that it's included in requests to the LangGraph server. export const GITHUB_TOKEN_COOKIE = "x-github-access-token"; +export const GITHUB_INSTALLATION_TOKEN_COOKIE = "x-github-installation-token"; diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts new file mode 100644 index 00000000..e3cc44af --- /dev/null +++ b/packages/shared/src/crypto.ts @@ -0,0 +1,132 @@ +import * as crypto from "node:crypto"; + +/** + * Encryption utility for GitHub tokens using AES-256-GCM + * + * This module provides secure encryption and decryption of GitHub access tokens + * using AES-256-GCM encryption with authenticated encryption. + */ + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; // 96 bits (Standard for GCM) +const TAG_LENGTH = 16; // 128 bits + +/** + * Derives a 256-bit key from the provided encryption key string + * Uses SHA-256 to ensure consistent key length + */ +function deriveKey(encryptionKey: string): Buffer { + return crypto.createHash("sha256").update(encryptionKey).digest(); +} + +/** + * Encrypts a GitHub token using AES-256-GCM + * + * @param token - The GitHub access token to encrypt + * @param encryptionKey - The encryption key (will be hashed to 256 bits) + * @returns Base64 encoded encrypted data containing IV, encrypted token, and auth tag + * @throws Error if encryption fails or inputs are invalid + */ +export function encryptGitHubToken( + token: string, + encryptionKey: string, +): string { + if (!token || typeof token !== "string") { + throw new Error("Token must be a non-empty string"); + } + + if (!encryptionKey || typeof encryptionKey !== "string") { + throw new Error("Encryption key must be a non-empty string"); + } + + try { + // Generate a random IV for each encryption (12 bytes for GCM) + const iv = crypto.randomBytes(IV_LENGTH); + + // Derive the encryption key + const key = deriveKey(encryptionKey); + + // Create cipher + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + // Encrypt the token + const encryptedBuffer = Buffer.concat([ + cipher.update(token, "utf8"), + cipher.final(), + ]); + + // Get the authentication tag + const tag = cipher.getAuthTag(); + + // Combine IV, encrypted data, and tag into a single base64 string + // Format: IV (12 bytes) + EncryptedData + AuthTag (16 bytes) + const combined = Buffer.concat([iv, encryptedBuffer, tag]); + return combined.toString("base64"); + } catch (error) { + throw new Error( + `Failed to encrypt token: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} + +/** + * Decrypts a GitHub token using AES-256-GCM + * + * @param encryptedToken - Base64 encoded encrypted data from encryptGitHubToken + * @param encryptionKey - The encryption key used for encryption + * @returns The decrypted GitHub access token + * @throws Error if decryption fails or inputs are invalid + */ +export function decryptGitHubToken( + encryptedToken: string, + encryptionKey: string, +): string { + if (!encryptedToken || typeof encryptedToken !== "string") { + throw new Error("Encrypted token must be a non-empty string"); + } + + if (!encryptionKey || typeof encryptionKey !== "string") { + throw new Error("Encryption key must be a non-empty string"); + } + + try { + // Decode the combined data + const combined = Buffer.from(encryptedToken, "base64"); + + // Minimum length: IV_LENGTH + TAG_LENGTH + 1 byte for data + if (combined.length < IV_LENGTH + TAG_LENGTH + 1) { + throw new Error("Invalid encrypted token format: too short or malformed"); + } + + // Extract IV, encrypted data, and tag + // IV is first IV_LENGTH bytes + // AuthTag is last TAG_LENGTH bytes + // Encrypted data is in between + const iv = combined.subarray(0, IV_LENGTH); + const tag = combined.subarray(combined.length - TAG_LENGTH); + const encrypted = combined.subarray( + IV_LENGTH, + combined.length - TAG_LENGTH, + ); + + // Derive the encryption key + const key = deriveKey(encryptionKey); + + // Create decipher + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + // Decrypt the token + // 'encrypted' is a Buffer, so no input encoding is specified for update() + const decryptedBuffer = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return decryptedBuffer.toString("utf8"); + } catch (error) { + throw new Error( + `Failed to decrypt token: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +} diff --git a/packages/shared/src/open-swe/types.ts b/packages/shared/src/open-swe/types.ts index 15e0dfb0..5e0fec36 100644 --- a/packages/shared/src/open-swe/types.ts +++ b/packages/shared/src/open-swe/types.ts @@ -13,7 +13,10 @@ import { type UIMessage, type RemoveUIMessage, } from "@langchain/langgraph-sdk/react-ui"; -import { GITHUB_TOKEN_COOKIE } from "../constants.js"; +import { + GITHUB_INSTALLATION_TOKEN_COOKIE, + GITHUB_TOKEN_COOKIE, +} from "../constants.js"; import { withLangGraph } from "@langchain/langgraph/zod"; import { BaseMessage } from "@langchain/core/messages"; @@ -310,6 +313,11 @@ export const GraphConfigurationMetadata: { type: "hidden", }, }, + [GITHUB_INSTALLATION_TOKEN_COOKIE]: { + x_open_swe_ui_config: { + type: "hidden", + }, + }, }; export const GraphConfiguration = z.object({ @@ -437,6 +445,16 @@ export const GraphConfiguration = z.object({ .string() .optional() .langgraph.metadata(GraphConfigurationMetadata[GITHUB_TOKEN_COOKIE]), + /** + * The installation token from the GitHub app. This token allows us to take actions + * on the repos the user has granted us access to, but on behalf of the app, not the user. + */ + [GITHUB_INSTALLATION_TOKEN_COOKIE]: z + .string() + .optional() + .langgraph.metadata( + GraphConfigurationMetadata[GITHUB_INSTALLATION_TOKEN_COOKIE], + ), }); export type GraphConfig = LangGraphRunnableConfig<