diff --git a/.env.example b/.env.example index 2a12a0d..80c43f9 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,7 @@ DATABASE_URL=sqlite://data/gitea-mirror.db # Security BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production BETTER_AUTH_URL=http://localhost:4321 +# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48 # Optional GitHub/Gitea Mirror Configuration (for docker-compose, can also be set via web UI) # Uncomment and set as needed. These are passed as environment variables to the container. diff --git a/.gitignore b/.gitignore index e644028..a715073 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ certs/*.crt certs/*.pem certs/*.cer !certs/README.md + +# Hosted version documentation (local only) +docs/HOSTED_VERSION.md diff --git a/README.md b/README.md index 7a972d2..f78a9d9 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,25 @@ bun run build - **APIs**: GitHub (Octokit), Gitea REST API - **Auth**: JWT tokens with bcryptjs password hashing +## Security + +### Token Encryption +- All GitHub and Gitea API tokens are encrypted at rest using AES-256-GCM +- Encryption is automatic and transparent to users +- Set `ENCRYPTION_SECRET` environment variable for production deployments +- Falls back to `BETTER_AUTH_SECRET` or `JWT_SECRET` if not set + +### Password Security +- User passwords are hashed using bcrypt (via Better Auth) +- Never stored in plaintext +- Secure session management with JWT tokens + +### Migration +If upgrading from a version without token encryption: +```bash +bun run migrate:encrypt-tokens +``` + ## Contributing Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. diff --git a/package.json b/package.json index 27dc0db..a660ec4 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "db:check": "bun drizzle-kit check", "db:studio": "bun drizzle-kit studio", "migrate:better-auth": "bun scripts/migrate-to-better-auth.ts", + "migrate:encrypt-tokens": "bun scripts/migrate-tokens-encryption.ts", "startup-recovery": "bun scripts/startup-recovery.ts", "startup-recovery-force": "bun scripts/startup-recovery.ts --force", "test-recovery": "bun scripts/test-recovery.ts", diff --git a/scripts/migrate-tokens-encryption.ts b/scripts/migrate-tokens-encryption.ts new file mode 100644 index 0000000..f9c1c6c --- /dev/null +++ b/scripts/migrate-tokens-encryption.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env bun +/** + * Migration script to encrypt existing GitHub and Gitea tokens in the database + * Run with: bun run scripts/migrate-tokens-encryption.ts + */ + +import { db, configs } from "../src/lib/db"; +import { eq } from "drizzle-orm"; +import { encrypt, isEncrypted, migrateToken } from "../src/lib/utils/encryption"; + +async function migrateTokens() { + console.log("Starting token encryption migration..."); + + try { + // Fetch all configs + const allConfigs = await db.select().from(configs); + + console.log(`Found ${allConfigs.length} configurations to check`); + + let migratedCount = 0; + let skippedCount = 0; + let errorCount = 0; + + for (const config of allConfigs) { + try { + let githubUpdated = false; + let giteaUpdated = false; + + // Parse configs + const githubConfig = typeof config.githubConfig === "string" + ? JSON.parse(config.githubConfig) + : config.githubConfig; + + const giteaConfig = typeof config.giteaConfig === "string" + ? JSON.parse(config.giteaConfig) + : config.giteaConfig; + + // Check and migrate GitHub token + if (githubConfig.token) { + if (!isEncrypted(githubConfig.token)) { + console.log(`Encrypting GitHub token for config ${config.id} (user: ${config.userId})`); + githubConfig.token = encrypt(githubConfig.token); + githubUpdated = true; + } else { + console.log(`GitHub token already encrypted for config ${config.id}`); + } + } + + // Check and migrate Gitea token + if (giteaConfig.token) { + if (!isEncrypted(giteaConfig.token)) { + console.log(`Encrypting Gitea token for config ${config.id} (user: ${config.userId})`); + giteaConfig.token = encrypt(giteaConfig.token); + giteaUpdated = true; + } else { + console.log(`Gitea token already encrypted for config ${config.id}`); + } + } + + // Update config if any tokens were migrated + if (githubUpdated || giteaUpdated) { + await db + .update(configs) + .set({ + githubConfig, + giteaConfig, + updatedAt: new Date(), + }) + .where(eq(configs.id, config.id)); + + migratedCount++; + console.log(`✓ Config ${config.id} updated successfully`); + } else { + skippedCount++; + } + + } catch (error) { + errorCount++; + console.error(`✗ Error processing config ${config.id}:`, error); + } + } + + console.log("\n=== Migration Summary ==="); + console.log(`Total configs: ${allConfigs.length}`); + console.log(`Migrated: ${migratedCount}`); + console.log(`Skipped (already encrypted): ${skippedCount}`); + console.log(`Errors: ${errorCount}`); + + if (errorCount > 0) { + console.error("\n⚠️ Some configs failed to migrate. Please check the errors above."); + process.exit(1); + } else { + console.log("\n✅ Token encryption migration completed successfully!"); + } + + } catch (error) { + console.error("Fatal error during migration:", error); + process.exit(1); + } +} + +// Verify environment setup +function verifyEnvironment() { + const requiredEnvVars = ["ENCRYPTION_SECRET", "JWT_SECRET", "BETTER_AUTH_SECRET"]; + const availableSecrets = requiredEnvVars.filter(varName => process.env[varName]); + + if (availableSecrets.length === 0) { + console.error("❌ No encryption secret found!"); + console.error("Please set one of the following environment variables:"); + console.error(" - ENCRYPTION_SECRET (recommended)"); + console.error(" - JWT_SECRET"); + console.error(" - BETTER_AUTH_SECRET"); + process.exit(1); + } + + console.log(`Using encryption secret from: ${availableSecrets[0]}`); +} + +// Main execution +async function main() { + console.log("=== Gitea Mirror Token Encryption Migration ===\n"); + + // Verify environment + verifyEnvironment(); + + // Run migration + await migrateTokens(); + + process.exit(0); +} + +main().catch((error) => { + console.error("Unexpected error:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/lib/gitea.ts b/src/lib/gitea.ts index d79eef0..fb4aedf 100644 --- a/src/lib/gitea.ts +++ b/src/lib/gitea.ts @@ -11,6 +11,7 @@ import { httpPost, httpGet } from "./http-client"; import { createMirrorJob } from "./helpers"; import { db, organizations, repositories } from "./db"; import { eq, and } from "drizzle-orm"; +import { decryptConfigTokens } from "./utils/config-encryption"; /** * Helper function to get organization configuration including destination override @@ -183,12 +184,15 @@ export const isRepoPresentInGitea = async ({ throw new Error("Gitea config is required."); } + // Decrypt config tokens for API usage + const decryptedConfig = decryptConfigTokens(config as Config); + // Check if the repository exists at the specified owner location const response = await fetch( `${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`, { headers: { - Authorization: `token ${config.giteaConfig.token}`, + Authorization: `token ${decryptedConfig.giteaConfig.token}`, }, } ); @@ -371,7 +375,7 @@ export const mirrorGithubRepoToGitea = async ({ service: "git", }, { - Authorization: `token ${config.giteaConfig.token}`, + Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); @@ -480,7 +484,7 @@ export async function getOrCreateGiteaOrg({ `${config.giteaConfig.url}/api/v1/orgs/${orgName}`, { headers: { - Authorization: `token ${config.giteaConfig.token}`, + Authorization: `token ${decryptedConfig.giteaConfig.token}`, "Content-Type": "application/json", }, } @@ -533,7 +537,7 @@ export async function getOrCreateGiteaOrg({ const createRes = await fetch(`${config.giteaConfig.url}/api/v1/orgs`, { method: "POST", headers: { - Authorization: `token ${config.giteaConfig.token}`, + Authorization: `token ${decryptedConfig.giteaConfig.token}`, "Content-Type": "application/json", }, body: JSON.stringify({ @@ -720,7 +724,7 @@ export async function mirrorGitHubRepoToGiteaOrg({ private: repository.isPrivate, }, { - Authorization: `token ${config.giteaConfig.token}`, + Authorization: `token ${decryptedConfig.giteaConfig.token}`, } ); @@ -1074,6 +1078,9 @@ export const syncGiteaRepo = async ({ throw new Error("Gitea config is required."); } + // Decrypt config tokens for API usage + const decryptedConfig = decryptConfigTokens(config as Config); + console.log(`Syncing repository ${repository.name}`); // Mark repo as "syncing" in DB @@ -1200,6 +1207,9 @@ export const mirrorGitRepoIssuesToGitea = async ({ throw new Error("Missing GitHub or Gitea configuration."); } + // Decrypt config tokens for API usage + const decryptedConfig = decryptConfigTokens(config as Config); + const [owner, repo] = repository.fullName.split("/"); // Fetch GitHub issues diff --git a/src/lib/recovery.ts b/src/lib/recovery.ts index 5129ec1..0d00aa8 100644 --- a/src/lib/recovery.ts +++ b/src/lib/recovery.ts @@ -11,6 +11,7 @@ import { createGitHubClient } from './github'; import { processWithResilience } from './utils/concurrency'; import { repositoryVisibilityEnum, repoStatusEnum } from '@/types/Repository'; import type { Repository } from './db/schema'; +import { getDecryptedGitHubToken } from './utils/config-encryption'; // Recovery state tracking let recoveryInProgress = false; @@ -262,7 +263,8 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) { // Create GitHub client with error handling let octokit; try { - octokit = createGitHubClient(config.githubConfig.token); + const decryptedToken = getDecryptedGitHubToken(config); + octokit = createGitHubClient(decryptedToken); } catch (error) { throw new Error(`Failed to create GitHub client: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/lib/utils/config-encryption.ts b/src/lib/utils/config-encryption.ts new file mode 100644 index 0000000..a223c18 --- /dev/null +++ b/src/lib/utils/config-encryption.ts @@ -0,0 +1,52 @@ +import { decrypt } from "./encryption"; +import type { Config } from "@/types/config"; + +/** + * Decrypts tokens in a config object for use in API calls + * @param config The config object with potentially encrypted tokens + * @returns Config object with decrypted tokens + */ +export function decryptConfigTokens(config: Config): Config { + const decryptedConfig = { ...config }; + + // Deep clone the config objects + if (config.githubConfig) { + decryptedConfig.githubConfig = { ...config.githubConfig }; + if (config.githubConfig.token) { + decryptedConfig.githubConfig.token = decrypt(config.githubConfig.token); + } + } + + if (config.giteaConfig) { + decryptedConfig.giteaConfig = { ...config.giteaConfig }; + if (config.giteaConfig.token) { + decryptedConfig.giteaConfig.token = decrypt(config.giteaConfig.token); + } + } + + return decryptedConfig; +} + +/** + * Gets a decrypted GitHub token from config + * @param config The config object + * @returns Decrypted GitHub token + */ +export function getDecryptedGitHubToken(config: Config): string { + if (!config.githubConfig?.token) { + throw new Error("GitHub token not found in config"); + } + return decrypt(config.githubConfig.token); +} + +/** + * Gets a decrypted Gitea token from config + * @param config The config object + * @returns Decrypted Gitea token + */ +export function getDecryptedGiteaToken(config: Config): string { + if (!config.giteaConfig?.token) { + throw new Error("Gitea token not found in config"); + } + return decrypt(config.giteaConfig.token); +} \ No newline at end of file diff --git a/src/lib/utils/encryption.ts b/src/lib/utils/encryption.ts new file mode 100644 index 0000000..0c72f83 --- /dev/null +++ b/src/lib/utils/encryption.ts @@ -0,0 +1,169 @@ +import * as crypto from "crypto"; + +// Encryption configuration +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 16; // 128 bits +const SALT_LENGTH = 32; // 256 bits +const TAG_LENGTH = 16; // 128 bits +const KEY_LENGTH = 32; // 256 bits +const ITERATIONS = 100000; // PBKDF2 iterations + +// Get or generate encryption key +function getEncryptionKey(): Buffer { + const secret = process.env.ENCRYPTION_SECRET || process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET; + + if (!secret) { + throw new Error("No encryption secret found. Please set ENCRYPTION_SECRET environment variable."); + } + + // Use a static salt derived from the secret for consistent key generation + // This ensures the same key is generated across application restarts + const salt = crypto.createHash('sha256').update('gitea-mirror-salt' + secret).digest(); + + return crypto.pbkdf2Sync(secret, salt, ITERATIONS, KEY_LENGTH, 'sha256'); +} + +export interface EncryptedData { + encrypted: string; + iv: string; + salt: string; + tag: string; + version: number; +} + +/** + * Encrypts sensitive data like API tokens + * @param plaintext The data to encrypt + * @returns Encrypted data with metadata + */ +export function encrypt(plaintext: string): string { + if (!plaintext) { + return ''; + } + + try { + const key = getEncryptionKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const salt = crypto.randomBytes(SALT_LENGTH); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(plaintext, 'utf8'), + cipher.final() + ]); + + const tag = cipher.getAuthTag(); + + const encryptedData: EncryptedData = { + encrypted: encrypted.toString('base64'), + iv: iv.toString('base64'), + salt: salt.toString('base64'), + tag: tag.toString('base64'), + version: 1 + }; + + // Return as base64 encoded JSON for easy storage + return Buffer.from(JSON.stringify(encryptedData)).toString('base64'); + } catch (error) { + console.error('Encryption error:', error); + throw new Error('Failed to encrypt data'); + } +} + +/** + * Decrypts encrypted data + * @param encryptedString The encrypted data string + * @returns Decrypted plaintext + */ +export function decrypt(encryptedString: string): string { + if (!encryptedString) { + return ''; + } + + try { + // Check if it's already plaintext (for backward compatibility during migration) + if (!isEncrypted(encryptedString)) { + return encryptedString; + } + + const encryptedData: EncryptedData = JSON.parse( + Buffer.from(encryptedString, 'base64').toString('utf8') + ); + + const key = getEncryptionKey(); + const iv = Buffer.from(encryptedData.iv, 'base64'); + const tag = Buffer.from(encryptedData.tag, 'base64'); + const encrypted = Buffer.from(encryptedData.encrypted, 'base64'); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(tag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]); + + return decrypted.toString('utf8'); + } catch (error) { + // If decryption fails, check if it's plaintext (backward compatibility) + try { + JSON.parse(Buffer.from(encryptedString, 'base64').toString('utf8')); + throw error; // It was encrypted but failed to decrypt + } catch { + // Not encrypted, return as-is for backward compatibility + console.warn('Token appears to be unencrypted, returning as-is for backward compatibility'); + return encryptedString; + } + } +} + +/** + * Checks if a string is encrypted + * @param value The string to check + * @returns true if encrypted, false otherwise + */ +export function isEncrypted(value: string): boolean { + if (!value) { + return false; + } + + try { + const decoded = Buffer.from(value, 'base64').toString('utf8'); + const data = JSON.parse(decoded); + return data.version === 1 && data.encrypted && data.iv && data.tag; + } catch { + return false; + } +} + +/** + * Migrates unencrypted tokens to encrypted format + * @param token The token to migrate + * @returns Encrypted token if it wasn't already encrypted + */ +export function migrateToken(token: string): string { + if (!token || isEncrypted(token)) { + return token; + } + + return encrypt(token); +} + +/** + * Generates a secure random token + * @param length Token length in bytes (default: 32) + * @returns Hex encoded random token + */ +export function generateSecureToken(length: number = 32): string { + return crypto.randomBytes(length).toString('hex'); +} + +/** + * Hashes a value using SHA-256 (for non-reversible values like API keys for comparison) + * @param value The value to hash + * @returns Hex encoded hash + */ +export function hashValue(value: string): string { + return crypto.createHash('sha256').update(value).digest('hex'); +} \ No newline at end of file diff --git a/src/pages/api/config/index.ts b/src/pages/api/config/index.ts index a7e7590..0465f43 100644 --- a/src/pages/api/config/index.ts +++ b/src/pages/api/config/index.ts @@ -5,6 +5,7 @@ import { eq } from "drizzle-orm"; import { calculateCleanupInterval } from "@/lib/cleanup-service"; import { createSecureErrorResponse } from "@/lib/utils"; import { mapUiToDbConfig, mapDbToUiConfig } from "@/lib/utils/config-mapper"; +import { encrypt, decrypt, migrateToken } from "@/lib/utils/encryption"; export const POST: APIRoute = async ({ request }) => { try { @@ -55,17 +56,27 @@ export const POST: APIRoute = async ({ request }) => { ? JSON.parse(existingConfig.giteaConfig) : existingConfig.giteaConfig; + // Decrypt existing tokens before preserving if (!mappedGithubConfig.token && existingGithub.token) { - mappedGithubConfig.token = existingGithub.token; + mappedGithubConfig.token = decrypt(existingGithub.token); } if (!mappedGiteaConfig.token && existingGitea.token) { - mappedGiteaConfig.token = existingGitea.token; + mappedGiteaConfig.token = decrypt(existingGitea.token); } } catch (tokenError) { console.error("Failed to preserve tokens:", tokenError); } } + + // Encrypt tokens before saving + if (mappedGithubConfig.token) { + mappedGithubConfig.token = encrypt(mappedGithubConfig.token); + } + + if (mappedGiteaConfig.token) { + mappedGiteaConfig.token = encrypt(mappedGiteaConfig.token); + } // Process schedule config - set/update nextRun if enabled, clear if disabled const processedScheduleConfig = { ...scheduleConfig }; @@ -279,15 +290,54 @@ export const GET: APIRoute = async ({ request }) => { // Map database structure to UI structure const dbConfig = config[0]; - const uiConfig = mapDbToUiConfig(dbConfig); - return new Response(JSON.stringify({ - ...dbConfig, - ...uiConfig, - }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); + // Decrypt tokens before sending to UI + try { + const githubConfig = typeof dbConfig.githubConfig === "string" + ? JSON.parse(dbConfig.githubConfig) + : dbConfig.githubConfig; + + const giteaConfig = typeof dbConfig.giteaConfig === "string" + ? JSON.parse(dbConfig.giteaConfig) + : dbConfig.giteaConfig; + + // Decrypt tokens + if (githubConfig.token) { + githubConfig.token = decrypt(githubConfig.token); + } + + if (giteaConfig.token) { + giteaConfig.token = decrypt(giteaConfig.token); + } + + // Create modified config with decrypted tokens + const decryptedConfig = { + ...dbConfig, + githubConfig, + giteaConfig + }; + + const uiConfig = mapDbToUiConfig(decryptedConfig); + + return new Response(JSON.stringify({ + ...dbConfig, + ...uiConfig, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (error) { + console.error("Failed to decrypt tokens:", error); + // Return config without decrypting tokens if there's an error + const uiConfig = mapDbToUiConfig(dbConfig); + return new Response(JSON.stringify({ + ...dbConfig, + ...uiConfig, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } } catch (error) { return createSecureErrorResponse(error, "config fetch", 500); } diff --git a/src/pages/api/job/mirror-org.ts b/src/pages/api/job/mirror-org.ts index dc40095..d9328aa 100644 --- a/src/pages/api/job/mirror-org.ts +++ b/src/pages/api/job/mirror-org.ts @@ -9,6 +9,7 @@ import { type MembershipRole } from "@/types/organizations"; import { createSecureErrorResponse } from "@/lib/utils"; import { processWithResilience } from "@/lib/utils/concurrency"; import { v4 as uuidv4 } from "uuid"; +import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption"; export const POST: APIRoute = async ({ request }) => { try { @@ -71,7 +72,8 @@ export const POST: APIRoute = async ({ request }) => { } // Create a single Octokit instance to be reused - const octokit = createGitHubClient(config.githubConfig.token); + const decryptedToken = getDecryptedGitHubToken(config); + const octokit = createGitHubClient(decryptedToken); // Define the concurrency limit - adjust based on API rate limits // Using a lower concurrency for organizations since each org might contain many repos diff --git a/src/pages/api/job/mirror-repo.ts b/src/pages/api/job/mirror-repo.ts index 4e6acea..60bd3af 100644 --- a/src/pages/api/job/mirror-repo.ts +++ b/src/pages/api/job/mirror-repo.ts @@ -9,6 +9,7 @@ import { getGiteaRepoOwnerAsync, } from "@/lib/gitea"; import { createGitHubClient } from "@/lib/github"; +import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption"; import { processWithResilience } from "@/lib/utils/concurrency"; import { createSecureErrorResponse } from "@/lib/utils"; @@ -73,7 +74,8 @@ export const POST: APIRoute = async ({ request }) => { } // Create a single Octokit instance to be reused - const octokit = createGitHubClient(config.githubConfig.token); + const decryptedToken = getDecryptedGitHubToken(config); + const octokit = createGitHubClient(decryptedToken); // Define the concurrency limit - adjust based on API rate limits const CONCURRENCY_LIMIT = 3; diff --git a/src/pages/api/job/retry-repo.ts b/src/pages/api/job/retry-repo.ts index f283629..560295c 100644 --- a/src/pages/api/job/retry-repo.ts +++ b/src/pages/api/job/retry-repo.ts @@ -13,6 +13,7 @@ import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry"; import { processWithRetry } from "@/lib/utils/concurrency"; import { createMirrorJob } from "@/lib/helpers"; import { createSecureErrorResponse } from "@/lib/utils"; +import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption"; export const POST: APIRoute = async ({ request }) => { try { @@ -71,8 +72,11 @@ export const POST: APIRoute = async ({ request }) => { // Start background retry with parallel processing setTimeout(async () => { // Create a single Octokit instance to be reused if needed - const octokit = config.githubConfig.token - ? createGitHubClient(config.githubConfig.token) + const decryptedToken = config.githubConfig.token + ? getDecryptedGitHubToken(config) + : null; + const octokit = decryptedToken + ? createGitHubClient(decryptedToken) : null; // Define the concurrency limit - adjust based on API rate limits diff --git a/src/pages/api/sync/index.ts b/src/pages/api/sync/index.ts index 2ada2e3..ba6e00f 100644 --- a/src/pages/api/sync/index.ts +++ b/src/pages/api/sync/index.ts @@ -10,6 +10,7 @@ import { getGithubStarredRepositories, } from "@/lib/github"; import { jsonResponse, createSecureErrorResponse } from "@/lib/utils"; +import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption"; export const POST: APIRoute = async ({ request }) => { const url = new URL(request.url); @@ -33,16 +34,16 @@ export const POST: APIRoute = async ({ request }) => { }); } - const token = config.githubConfig?.token; - - if (!token) { + if (!config.githubConfig?.token) { return jsonResponse({ data: { error: "GitHub token is missing in config" }, status: 400, }); } - const octokit = createGitHubClient(token); + // Decrypt the GitHub token before using it + const decryptedToken = getDecryptedGitHubToken(config); + const octokit = createGitHubClient(decryptedToken); // Fetch GitHub data in parallel const [basicAndForkedRepos, starredRepos, gitOrgs] = await Promise.all([