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

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

3
.gitignore vendored
View File

@@ -31,3 +31,6 @@ certs/*.crt
certs/*.pem
certs/*.cer
!certs/README.md
# Hosted version documentation (local only)
docs/HOSTED_VERSION.md

View File

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

View File

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

View File

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

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