import { createCipheriv, createDecipheriv, randomBytes, scrypt } from 'crypto';
import { promisify } from 'util';
import { DatabaseConnection } from '../database/connection.js';
import { Logger } from '../utils/logger.js';
import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts';
import { CostExplorerClient, GetDimensionValuesCommand } from '@aws-sdk/client-cost-explorer';
export interface AWSCredentials {
accessKeyId: string;
secretAccessKey: string;
region?: string;
sessionToken?: string;
}
export interface AWSAccount {
id: string;
accountName: string;
credentials: AWSCredentials;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface EncryptedCredentials {
encryptedData: string;
iv: string;
salt: string;
}
export class CredentialManager {
private static instance: CredentialManager;
private db!: DatabaseConnection;
private logger: Logger;
private encryptionKey: string;
private constructor() {
this.logger = Logger.getInstance();
this.encryptionKey = process.env.ENCRYPTION_KEY || this.generateEncryptionKey();
}
public static async getInstance(): Promise<CredentialManager> {
if (!CredentialManager.instance) {
CredentialManager.instance = new CredentialManager();
CredentialManager.instance.db = await DatabaseConnection.getInstance();
}
return CredentialManager.instance;
}
private generateEncryptionKey(): string {
// In production, this should be loaded from a secure key management service
const key = randomBytes(32).toString('hex');
this.logger.warn('Generated new encryption key. In production, use a secure key management service.');
return key;
}
private async encryptCredentials(credentials: AWSCredentials): Promise<EncryptedCredentials> {
const salt = randomBytes(16);
const iv = randomBytes(16);
const scryptAsync = promisify(scrypt);
const key = await scryptAsync(this.encryptionKey, salt, 32) as Buffer;
const cipher = createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(JSON.stringify(credentials), 'utf8', 'hex');
encrypted += cipher.final('hex');
return {
encryptedData: encrypted,
iv: iv.toString('hex'),
salt: salt.toString('hex')
};
}
private async decryptCredentials(encryptedCreds: EncryptedCredentials): Promise<AWSCredentials> {
const salt = Buffer.from(encryptedCreds.salt, 'hex');
const iv = Buffer.from(encryptedCreds.iv, 'hex');
const scryptAsync = promisify(scrypt);
const key = await scryptAsync(this.encryptionKey, salt, 32) as Buffer;
const decipher = createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encryptedCreds.encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return JSON.parse(decrypted);
}
public async validateCredentials(credentials: AWSCredentials): Promise<{ valid: boolean; accountId?: string; error?: string; hasCostExplorerAccess?: boolean }> {
try {
// Test credentials with STS GetCallerIdentity
const credentialsConfig: any = {
accessKeyId: credentials.accessKeyId,
secretAccessKey: credentials.secretAccessKey
};
if (credentials.sessionToken) {
credentialsConfig.sessionToken = credentials.sessionToken;
}
const stsClient = new STSClient({
region: credentials.region || 'us-east-1',
credentials: credentialsConfig
});
const identityCommand = new GetCallerIdentityCommand({});
const identityResponse = await stsClient.send(identityCommand);
// Test access to Cost Explorer (optional - warn if fails but don't reject)
let hasCostExplorerAccess = false;
try {
const costExplorerClient = new CostExplorerClient({
region: credentials.region || 'us-east-1',
credentials: credentialsConfig
});
const dimensionsCommand = new GetDimensionValuesCommand({
TimePeriod: {
Start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
End: new Date().toISOString().split('T')[0]
},
Dimension: 'SERVICE'
});
await costExplorerClient.send(dimensionsCommand);
hasCostExplorerAccess = true;
} catch (ceError: any) {
this.logger.warn('Cost Explorer access test failed - billing data may be limited', {
error: ceError.message,
accountId: identityResponse.Account
});
}
this.logger.info('AWS credentials validated successfully', {
accountId: identityResponse.Account,
userId: identityResponse.UserId,
hasCostExplorerAccess
});
return {
valid: true,
accountId: identityResponse.Account!,
hasCostExplorerAccess
};
} catch (error: any) {
this.logger.error('AWS credential validation failed', { error: error.message });
let errorMessage = 'Invalid AWS credentials';
if (error.name === 'UnauthorizedOperation') {
errorMessage = 'Credentials lack required permissions for Cost Explorer access';
} else if (error.name === 'InvalidUserID.NotFound') {
errorMessage = 'Invalid access key ID';
} else if (error.name === 'SignatureDoesNotMatch') {
errorMessage = 'Invalid secret access key';
}
return {
valid: false,
error: errorMessage
};
}
}
public async storeCredentials(accountName: string, credentials: AWSCredentials): Promise<string> {
// Validate credentials first
const validation = await this.validateCredentials(credentials);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid credentials');
}
// Check if account already exists
const existingAccount = await this.getAccountByName(accountName);
if (existingAccount) {
throw new Error(`Account with name '${accountName}' already exists`);
}
// Encrypt credentials
const encryptedCreds = await this.encryptCredentials(credentials);
const accountId = this.generateAccountId();
// Store in database
await this.db.run(
`INSERT INTO aws_accounts (id, account_name, encrypted_credentials, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)`,
[
accountId,
accountName,
JSON.stringify(encryptedCreds),
1,
new Date().toISOString(),
new Date().toISOString()
]
);
this.logger.info('AWS credentials stored successfully', {
accountId,
accountName,
awsAccountId: validation.accountId
});
return accountId;
}
public async getCredentials(accountId: string): Promise<AWSCredentials | null> {
const result = await this.db.get(
'SELECT encrypted_credentials FROM aws_accounts WHERE id = ? AND is_active = 1',
[accountId]
) as { encrypted_credentials: string } | undefined;
if (!result) {
return null;
}
const encryptedCreds: EncryptedCredentials = JSON.parse(result.encrypted_credentials);
return await this.decryptCredentials(encryptedCreds);
}
public async getAllAccounts(): Promise<AWSAccount[]> {
const results = await this.db.all(
'SELECT id, account_name, encrypted_credentials, is_active, created_at, updated_at FROM aws_accounts WHERE is_active = 1'
) as any[];
return await Promise.all(results.map(async row => ({
id: row.id,
accountName: row.account_name,
credentials: await this.decryptCredentials(JSON.parse(row.encrypted_credentials)),
isActive: Boolean(row.is_active),
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at)
})));
}
public async getAccountByName(accountName: string): Promise<AWSAccount | null> {
const result = await this.db.get(
'SELECT id, account_name, encrypted_credentials, is_active, created_at, updated_at FROM aws_accounts WHERE account_name = ? AND is_active = 1',
[accountName]
) as any;
if (!result) {
return null;
}
return {
id: result.id,
accountName: result.account_name,
credentials: await this.decryptCredentials(JSON.parse(result.encrypted_credentials)),
isActive: Boolean(result.is_active),
createdAt: new Date(result.created_at),
updatedAt: new Date(result.updated_at)
};
}
public async updateCredentials(accountId: string, credentials: AWSCredentials): Promise<void> {
// Validate new credentials
const validation = await this.validateCredentials(credentials);
if (!validation.valid) {
throw new Error(validation.error || 'Invalid credentials');
}
// Encrypt new credentials
const encryptedCreds = await this.encryptCredentials(credentials);
// Update in database
const result = await this.db.run(
'UPDATE aws_accounts SET encrypted_credentials = ?, updated_at = ? WHERE id = ? AND is_active = 1',
[JSON.stringify(encryptedCreds), new Date().toISOString(), accountId]
);
if (result.changes === 0) {
throw new Error('Account not found or inactive');
}
this.logger.info('AWS credentials updated successfully', {
accountId,
awsAccountId: validation.accountId
});
}
public async deleteAccount(accountId: string): Promise<void> {
const result = await this.db.run(
'UPDATE aws_accounts SET is_active = 0, updated_at = ? WHERE id = ?',
[new Date().toISOString(), accountId]
);
if (result.changes === 0) {
throw new Error('Account not found');
}
this.logger.info('AWS account deactivated', { accountId });
}
public async rotateCredentials(accountId: string, newCredentials: AWSCredentials): Promise<void> {
// This is a simplified rotation - in production, you'd want to:
// 1. Validate new credentials
// 2. Test that they work
// 3. Update the stored credentials
// 4. Optionally keep old credentials for a grace period
await this.updateCredentials(accountId, newCredentials);
this.logger.info('Credentials rotated successfully', { accountId });
}
public async clearAllCredentials(): Promise<void> {
await this.db.run('DELETE FROM aws_accounts');
this.logger.info('All stored credentials cleared');
}
private generateAccountId(): string {
return 'acc_' + randomBytes(16).toString('hex');
}
}