mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2025-12-08 12:36:44 +03:00
Added Encryptions to All stored token and passwords
This commit is contained in:
@@ -12,6 +12,7 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
|
|||||||
# Security
|
# Security
|
||||||
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
|
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
|
||||||
BETTER_AUTH_URL=http://localhost:4321
|
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)
|
# 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.
|
# Uncomment and set as needed. These are passed as environment variables to the container.
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,3 +31,6 @@ certs/*.crt
|
|||||||
certs/*.pem
|
certs/*.pem
|
||||||
certs/*.cer
|
certs/*.cer
|
||||||
!certs/README.md
|
!certs/README.md
|
||||||
|
|
||||||
|
# Hosted version documentation (local only)
|
||||||
|
docs/HOSTED_VERSION.md
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -199,6 +199,25 @@ bun run build
|
|||||||
- **APIs**: GitHub (Octokit), Gitea REST API
|
- **APIs**: GitHub (Octokit), Gitea REST API
|
||||||
- **Auth**: JWT tokens with bcryptjs password hashing
|
- **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
|
## 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.
|
Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"db:check": "bun drizzle-kit check",
|
"db:check": "bun drizzle-kit check",
|
||||||
"db:studio": "bun drizzle-kit studio",
|
"db:studio": "bun drizzle-kit studio",
|
||||||
"migrate:better-auth": "bun scripts/migrate-to-better-auth.ts",
|
"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": "bun scripts/startup-recovery.ts",
|
||||||
"startup-recovery-force": "bun scripts/startup-recovery.ts --force",
|
"startup-recovery-force": "bun scripts/startup-recovery.ts --force",
|
||||||
"test-recovery": "bun scripts/test-recovery.ts",
|
"test-recovery": "bun scripts/test-recovery.ts",
|
||||||
|
|||||||
135
scripts/migrate-tokens-encryption.ts
Normal file
135
scripts/migrate-tokens-encryption.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { httpPost, httpGet } from "./http-client";
|
|||||||
import { createMirrorJob } from "./helpers";
|
import { createMirrorJob } from "./helpers";
|
||||||
import { db, organizations, repositories } from "./db";
|
import { db, organizations, repositories } from "./db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
|
import { decryptConfigTokens } from "./utils/config-encryption";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function to get organization configuration including destination override
|
* Helper function to get organization configuration including destination override
|
||||||
@@ -183,12 +184,15 @@ export const isRepoPresentInGitea = async ({
|
|||||||
throw new Error("Gitea config is required.");
|
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
|
// Check if the repository exists at the specified owner location
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
|
`${config.giteaConfig.url}/api/v1/repos/${owner}/${repoName}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `token ${config.giteaConfig.token}`,
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -371,7 +375,7 @@ export const mirrorGithubRepoToGitea = async ({
|
|||||||
service: "git",
|
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}`,
|
`${config.giteaConfig.url}/api/v1/orgs/${orgName}`,
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `token ${config.giteaConfig.token}`,
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -533,7 +537,7 @@ export async function getOrCreateGiteaOrg({
|
|||||||
const createRes = await fetch(`${config.giteaConfig.url}/api/v1/orgs`, {
|
const createRes = await fetch(`${config.giteaConfig.url}/api/v1/orgs`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `token ${config.giteaConfig.token}`,
|
Authorization: `token ${decryptedConfig.giteaConfig.token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -720,7 +724,7 @@ export async function mirrorGitHubRepoToGiteaOrg({
|
|||||||
private: repository.isPrivate,
|
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.");
|
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}`);
|
console.log(`Syncing repository ${repository.name}`);
|
||||||
|
|
||||||
// Mark repo as "syncing" in DB
|
// Mark repo as "syncing" in DB
|
||||||
@@ -1200,6 +1207,9 @@ export const mirrorGitRepoIssuesToGitea = async ({
|
|||||||
throw new Error("Missing GitHub or Gitea configuration.");
|
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("/");
|
const [owner, repo] = repository.fullName.split("/");
|
||||||
|
|
||||||
// Fetch GitHub issues
|
// Fetch GitHub issues
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { createGitHubClient } from './github';
|
|||||||
import { processWithResilience } from './utils/concurrency';
|
import { processWithResilience } from './utils/concurrency';
|
||||||
import { repositoryVisibilityEnum, repoStatusEnum } from '@/types/Repository';
|
import { repositoryVisibilityEnum, repoStatusEnum } from '@/types/Repository';
|
||||||
import type { Repository } from './db/schema';
|
import type { Repository } from './db/schema';
|
||||||
|
import { getDecryptedGitHubToken } from './utils/config-encryption';
|
||||||
|
|
||||||
// Recovery state tracking
|
// Recovery state tracking
|
||||||
let recoveryInProgress = false;
|
let recoveryInProgress = false;
|
||||||
@@ -262,7 +263,8 @@ async function recoverMirrorJob(job: any, remainingItemIds: string[]) {
|
|||||||
// Create GitHub client with error handling
|
// Create GitHub client with error handling
|
||||||
let octokit;
|
let octokit;
|
||||||
try {
|
try {
|
||||||
octokit = createGitHubClient(config.githubConfig.token);
|
const decryptedToken = getDecryptedGitHubToken(config);
|
||||||
|
octokit = createGitHubClient(decryptedToken);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to create GitHub client: ${error instanceof Error ? error.message : String(error)}`);
|
throw new Error(`Failed to create GitHub client: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/lib/utils/config-encryption.ts
Normal file
52
src/lib/utils/config-encryption.ts
Normal 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
169
src/lib/utils/encryption.ts
Normal 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');
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { calculateCleanupInterval } from "@/lib/cleanup-service";
|
import { calculateCleanupInterval } from "@/lib/cleanup-service";
|
||||||
import { createSecureErrorResponse } from "@/lib/utils";
|
import { createSecureErrorResponse } from "@/lib/utils";
|
||||||
import { mapUiToDbConfig, mapDbToUiConfig } from "@/lib/utils/config-mapper";
|
import { mapUiToDbConfig, mapDbToUiConfig } from "@/lib/utils/config-mapper";
|
||||||
|
import { encrypt, decrypt, migrateToken } from "@/lib/utils/encryption";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
@@ -55,18 +56,28 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
? JSON.parse(existingConfig.giteaConfig)
|
? JSON.parse(existingConfig.giteaConfig)
|
||||||
: existingConfig.giteaConfig;
|
: existingConfig.giteaConfig;
|
||||||
|
|
||||||
|
// Decrypt existing tokens before preserving
|
||||||
if (!mappedGithubConfig.token && existingGithub.token) {
|
if (!mappedGithubConfig.token && existingGithub.token) {
|
||||||
mappedGithubConfig.token = existingGithub.token;
|
mappedGithubConfig.token = decrypt(existingGithub.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mappedGiteaConfig.token && existingGitea.token) {
|
if (!mappedGiteaConfig.token && existingGitea.token) {
|
||||||
mappedGiteaConfig.token = existingGitea.token;
|
mappedGiteaConfig.token = decrypt(existingGitea.token);
|
||||||
}
|
}
|
||||||
} catch (tokenError) {
|
} catch (tokenError) {
|
||||||
console.error("Failed to preserve tokens:", 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
|
// Process schedule config - set/update nextRun if enabled, clear if disabled
|
||||||
const processedScheduleConfig = { ...scheduleConfig };
|
const processedScheduleConfig = { ...scheduleConfig };
|
||||||
if (scheduleConfig.enabled) {
|
if (scheduleConfig.enabled) {
|
||||||
@@ -279,15 +290,54 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
|
|
||||||
// Map database structure to UI structure
|
// Map database structure to UI structure
|
||||||
const dbConfig = config[0];
|
const dbConfig = config[0];
|
||||||
const uiConfig = mapDbToUiConfig(dbConfig);
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
// Decrypt tokens before sending to UI
|
||||||
...dbConfig,
|
try {
|
||||||
...uiConfig,
|
const githubConfig = typeof dbConfig.githubConfig === "string"
|
||||||
}), {
|
? JSON.parse(dbConfig.githubConfig)
|
||||||
status: 200,
|
: dbConfig.githubConfig;
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
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) {
|
} catch (error) {
|
||||||
return createSecureErrorResponse(error, "config fetch", 500);
|
return createSecureErrorResponse(error, "config fetch", 500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { type MembershipRole } from "@/types/organizations";
|
|||||||
import { createSecureErrorResponse } from "@/lib/utils";
|
import { createSecureErrorResponse } from "@/lib/utils";
|
||||||
import { processWithResilience } from "@/lib/utils/concurrency";
|
import { processWithResilience } from "@/lib/utils/concurrency";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
@@ -71,7 +72,8 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a single Octokit instance to be reused
|
// 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
|
// Define the concurrency limit - adjust based on API rate limits
|
||||||
// Using a lower concurrency for organizations since each org might contain many repos
|
// Using a lower concurrency for organizations since each org might contain many repos
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getGiteaRepoOwnerAsync,
|
getGiteaRepoOwnerAsync,
|
||||||
} from "@/lib/gitea";
|
} from "@/lib/gitea";
|
||||||
import { createGitHubClient } from "@/lib/github";
|
import { createGitHubClient } from "@/lib/github";
|
||||||
|
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||||
import { processWithResilience } from "@/lib/utils/concurrency";
|
import { processWithResilience } from "@/lib/utils/concurrency";
|
||||||
import { createSecureErrorResponse } from "@/lib/utils";
|
import { createSecureErrorResponse } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -73,7 +74,8 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a single Octokit instance to be reused
|
// 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
|
// Define the concurrency limit - adjust based on API rate limits
|
||||||
const CONCURRENCY_LIMIT = 3;
|
const CONCURRENCY_LIMIT = 3;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { RetryRepoRequest, RetryRepoResponse } from "@/types/retry";
|
|||||||
import { processWithRetry } from "@/lib/utils/concurrency";
|
import { processWithRetry } from "@/lib/utils/concurrency";
|
||||||
import { createMirrorJob } from "@/lib/helpers";
|
import { createMirrorJob } from "@/lib/helpers";
|
||||||
import { createSecureErrorResponse } from "@/lib/utils";
|
import { createSecureErrorResponse } from "@/lib/utils";
|
||||||
|
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
try {
|
try {
|
||||||
@@ -71,8 +72,11 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
// Start background retry with parallel processing
|
// Start background retry with parallel processing
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
// Create a single Octokit instance to be reused if needed
|
// Create a single Octokit instance to be reused if needed
|
||||||
const octokit = config.githubConfig.token
|
const decryptedToken = config.githubConfig.token
|
||||||
? createGitHubClient(config.githubConfig.token)
|
? getDecryptedGitHubToken(config)
|
||||||
|
: null;
|
||||||
|
const octokit = decryptedToken
|
||||||
|
? createGitHubClient(decryptedToken)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Define the concurrency limit - adjust based on API rate limits
|
// Define the concurrency limit - adjust based on API rate limits
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getGithubStarredRepositories,
|
getGithubStarredRepositories,
|
||||||
} from "@/lib/github";
|
} from "@/lib/github";
|
||||||
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
import { jsonResponse, createSecureErrorResponse } from "@/lib/utils";
|
||||||
|
import { getDecryptedGitHubToken } from "@/lib/utils/config-encryption";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export const POST: APIRoute = async ({ request }) => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -33,16 +34,16 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = config.githubConfig?.token;
|
if (!config.githubConfig?.token) {
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: { error: "GitHub token is missing in config" },
|
data: { error: "GitHub token is missing in config" },
|
||||||
status: 400,
|
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
|
// Fetch GitHub data in parallel
|
||||||
const [basicAndForkedRepos, starredRepos, gitOrgs] = await Promise.all([
|
const [basicAndForkedRepos, starredRepos, gitOrgs] = await Promise.all([
|
||||||
|
|||||||
Reference in New Issue
Block a user