Added Encryptions to All stored token and passwords

This commit is contained in:
Arunavo Ray
2025-07-16 16:02:34 +05:30
parent 7cc4aa87f2
commit beedbaf9a4
14 changed files with 475 additions and 24 deletions

View File

@@ -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

View File

@@ -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)}`);
}

View File

@@ -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);
}

169
src/lib/utils/encryption.ts Normal file
View File

@@ -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');
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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([