open-swe/packages/shared/src/crypto.ts
Brace Sproul abc984409f
refactor: Proxy req utils, rename GITHUB_TOKEN_ENCRYPTION_KEY to SECRETS_ENCRYPTION_KEY (#391)
* refactor: Proxy req utils, rename GITHUB_TOKEN_ENCRYPTION_KEY to SECRETS_ENCRYPTION_KEY

* cr
2025-07-11 10:48:14 -07:00

131 lines
4 KiB
TypeScript

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 secret using AES-256-GCM
*
* @param secret - The secret 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 encryptSecret(secret: string, encryptionKey: string): string {
if (!secret || typeof secret !== "string") {
throw new Error("Secret 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 secret
const encryptedBuffer = Buffer.concat([
cipher.update(secret, "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 secret: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Decrypts a secret using AES-256-GCM
*
* @param encryptedSecret - Base64 encoded encrypted data from encryptSecret
* @param encryptionKey - The encryption key used for encryption
* @returns The decrypted secret
* @throws Error if decryption fails or inputs are invalid
*/
export function decryptSecret(
encryptedSecret: string,
encryptionKey: string,
): string {
if (!encryptedSecret || typeof encryptedSecret !== "string") {
throw new Error("Encrypted secret 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(encryptedSecret, "base64");
// Minimum length: IV_LENGTH + TAG_LENGTH + 1 byte for data
if (combined.length < IV_LENGTH + TAG_LENGTH + 1) {
throw new Error(
"Invalid encrypted secret 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 secret: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}