Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
298 changes: 298 additions & 0 deletions lib/keyManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
import { Keypair } from "@stellar/stellar-sdk";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Secret zeroization is ineffective because it wipes only a temporary Buffer copy, not the original key material.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At lib/keyManager.ts, line 178:

<comment>Secret zeroization is ineffective because it wipes only a temporary Buffer copy, not the original key material.</comment>

<file context>
@@ -0,0 +1,298 @@
+    for (const [key, value] of this.keyCache.entries()) {
+      // Overwrite sensitive data in memory
+      if (value.keypair.secret) {
+        const secretBuffer = Buffer.from(value.keypair.secret());
+        secretBuffer.fill(0);
+      }
</file context>

import * as crypto from "crypto";

/**
* Secure key management utilities for Stellar AgentKit
*
* πŸ”’ SECURITY FEATURES:
* - Encrypted key storage with AES-256-GCM
* - Memory cleanup after use
* - Key validation and rotation support
* - Secure random key generation
* - Protection against timing attacks
*/

export interface SecureKeyConfig {
encryptionKey?: string;
keyRotationInterval?: number; // in milliseconds
enableMemoryCleanup?: boolean;
}

export interface EncryptedKeyData {
encryptedKey: string;
iv: string;
tag: string;
timestamp: number;
}

export class SecureKeyManager {
private static instance: SecureKeyManager;
private encryptionKey: Buffer;
private keyCache = new Map<string, { keypair: Keypair; timestamp: number }>();
private config: Required<SecureKeyConfig>;

private constructor(config: SecureKeyConfig = {}) {
this.config = {
encryptionKey: config.encryptionKey || this.generateEncryptionKey(),
keyRotationInterval: config.keyRotationInterval || 3600000, // 1 hour

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: keyRotationInterval is accepted without validation and can collapse the cleanup timer into a 1ms loop when misconfigured, causing CPU churn/performance degradation.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At lib/keyManager.ts, line 37:

<comment>`keyRotationInterval` is accepted without validation and can collapse the cleanup timer into a 1ms loop when misconfigured, causing CPU churn/performance degradation.</comment>

<file context>
@@ -0,0 +1,298 @@
+  private constructor(config: SecureKeyConfig = {}) {
+    this.config = {
+      encryptionKey: config.encryptionKey || this.generateEncryptionKey(),
+      keyRotationInterval: config.keyRotationInterval || 3600000, // 1 hour
+      enableMemoryCleanup: config.enableMemoryCleanup ?? true,
+    };
</file context>

enableMemoryCleanup: config.enableMemoryCleanup ?? true,
};

this.encryptionKey = Buffer.from(this.config.encryptionKey, 'hex');

if (this.config.enableMemoryCleanup) {
this.setupMemoryCleanup();
}
}

public static getInstance(config?: SecureKeyConfig): SecureKeyManager {
if (!SecureKeyManager.instance) {
SecureKeyManager.instance = new SecureKeyManager(config);
}
return SecureKeyManager.instance;
}

/**
* Securely retrieve and validate a Stellar keypair from environment
*
* πŸ”’ SECURITY: Uses constant-time comparison and memory cleanup
*
* @param expectedPublicKey Optional public key for validation
* @returns Stellar keypair with automatic cleanup
*/
public getSecureKeypair(expectedPublicKey?: string): Keypair {
const secret = process.env.STELLAR_PRIVATE_KEY;

if (!secret) {
throw new Error(
"πŸ”’ STELLAR_PRIVATE_KEY not found in environment variables.\n" +
"Please set your private key securely in your .env file."
);
}

// Validate secret key format before processing
if (!this.isValidStellarSecret(secret)) {
throw new Error(
"πŸ”’ Invalid STELLAR_PRIVATE_KEY format.\n" +
"Stellar secret keys must start with 'S' and be 56 characters long."
);
}

let keypair: Keypair;
try {
keypair = Keypair.fromSecret(secret);
} catch (error) {
throw new Error(
"πŸ”’ Failed to create keypair from STELLAR_PRIVATE_KEY.\n" +
"Please verify your private key is valid."
);
}

// Constant-time public key validation to prevent timing attacks
if (expectedPublicKey && !this.constantTimeEquals(keypair.publicKey(), expectedPublicKey)) {
throw new Error(
"πŸ”’ STELLAR_PRIVATE_KEY does not match the expected public key.\n" +
"This prevents accidental use of wrong credentials."
);
}

// Cache keypair with timestamp for rotation
const cacheKey = this.hashPublicKey(keypair.publicKey());
this.keyCache.set(cacheKey, {
keypair,
timestamp: Date.now()
});

return keypair;
}

/**
* Encrypt a private key for secure storage
*
* @param privateKey Stellar private key to encrypt
* @returns Encrypted key data with IV and authentication tag
*/
public encryptPrivateKey(privateKey: string): EncryptedKeyData {
if (!this.isValidStellarSecret(privateKey)) {
throw new Error("Invalid Stellar private key format");
}

const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher('aes-256-gcm', this.encryptionKey);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0: Private key encryption uses deprecated createCipher/createDecipher and generates an IV that is never used, so AES-GCM is not actually being initialized with a per-encryption nonce.

Prompt for AI agents
Check if this issue is valid β€” if so, understand the root cause and fix it. At lib/keyManager.ts, line 121:

<comment>Private key encryption uses deprecated `createCipher`/`createDecipher` and generates an IV that is never used, so AES-GCM is not actually being initialized with a per-encryption nonce.</comment>

<file context>
@@ -0,0 +1,298 @@
+    }
+
+    const iv = crypto.randomBytes(16);
+    const cipher = crypto.createCipher('aes-256-gcm', this.encryptionKey);
+    cipher.setAAD(Buffer.from('stellar-agentkit-key'));
+
</file context>

cipher.setAAD(Buffer.from('stellar-agentkit-key'));

let encrypted = cipher.update(privateKey, 'utf8', 'hex');
encrypted += cipher.final('hex');
const tag = cipher.getAuthTag();

return {
encryptedKey: encrypted,
iv: iv.toString('hex'),
tag: tag.toString('hex'),
timestamp: Date.now()
};
}

/**
* Decrypt a previously encrypted private key
*
* @param encryptedData Encrypted key data
* @returns Decrypted private key
*/
public decryptPrivateKey(encryptedData: EncryptedKeyData): string {
try {
const decipher = crypto.createDecipher('aes-256-gcm', this.encryptionKey);
decipher.setAAD(Buffer.from('stellar-agentkit-key'));
decipher.setAuthTag(Buffer.from(encryptedData.tag, 'hex'));

let decrypted = decipher.update(encryptedData.encryptedKey, 'hex', 'utf8');
decrypted += decipher.final('utf8');

return decrypted;
} catch (error) {
throw new Error("Failed to decrypt private key - data may be corrupted or tampered with");
}
}

/**
* Generate a new Stellar keypair securely
*
* @returns New Stellar keypair with cryptographically secure randomness
*/
public generateSecureKeypair(): Keypair {
// Use crypto.randomBytes for cryptographically secure randomness
const randomBytes = crypto.randomBytes(32);
return Keypair.fromRawEd25519Seed(randomBytes);
}

/**
* Clear sensitive data from memory
*
* πŸ”’ SECURITY: Overwrites memory locations to prevent key recovery
*/
public clearMemory(): void {
// Clear keypair cache
for (const [key, value] of this.keyCache.entries()) {
// Overwrite sensitive data in memory
if (value.keypair.secret) {
const secretBuffer = Buffer.from(value.keypair.secret());
secretBuffer.fill(0);
}
}
this.keyCache.clear();

// Clear encryption key
this.encryptionKey.fill(0);
}

/**
* Check if cached keys need rotation based on age
*/
public rotateExpiredKeys(): void {
const now = Date.now();
const expiredKeys: string[] = [];

for (const [key, value] of this.keyCache.entries()) {
if (now - value.timestamp > this.config.keyRotationInterval) {
expiredKeys.push(key);
}
}

// Remove expired keys
expiredKeys.forEach(key => {
const cached = this.keyCache.get(key);
if (cached?.keypair.secret) {
const secretBuffer = Buffer.from(cached.keypair.secret());
secretBuffer.fill(0);
}
this.keyCache.delete(key);
});

if (expiredKeys.length > 0) {
console.log(`πŸ”’ Rotated ${expiredKeys.length} expired keys from cache`);
}
}

private generateEncryptionKey(): string {
return crypto.randomBytes(32).toString('hex');
}

private isValidStellarSecret(secret: string): boolean {
return /^S[A-Z0-9]{55}$/.test(secret);
}

private constantTimeEquals(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}

let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}

return result === 0;
}

private hashPublicKey(publicKey: string): string {
return crypto.createHash('sha256').update(publicKey).digest('hex');
}

private setupMemoryCleanup(): void {
// Cleanup on process exit
process.on('exit', () => this.clearMemory());
process.on('SIGINT', () => {
this.clearMemory();
process.exit(0);
});
process.on('SIGTERM', () => {
this.clearMemory();
process.exit(0);
});

// Periodic cleanup of expired keys
setInterval(() => {
this.rotateExpiredKeys();
}, this.config.keyRotationInterval / 4); // Check 4 times per rotation interval
}
}

/**
* Secure wrapper for the original getSigningKeypair function
*
* πŸ”’ SECURITY: Provides backward compatibility with enhanced security
*/
export function getSecureSigningKeypair(expectedPublicKey?: string): Keypair {
const keyManager = SecureKeyManager.getInstance();
return keyManager.getSecureKeypair(expectedPublicKey);
}

/**
* Secure transaction signing with automatic key cleanup
*
* @param txXDR Transaction XDR to sign
* @param networkPassphrase Network passphrase
* @param expectedPublicKey Optional public key validation
* @returns Signed transaction XDR
*/
export function signTransactionSecurely(
txXDR: string,
networkPassphrase: string,
expectedPublicKey?: string
): string {
const keyManager = SecureKeyManager.getInstance();
const keypair = keyManager.getSecureKeypair(expectedPublicKey);

try {
const { TransactionBuilder } = require("@stellar/stellar-sdk");
const transaction = TransactionBuilder.fromXDR(txXDR, networkPassphrase);
transaction.sign(keypair);
return transaction.toXDR();
} finally {
// Ensure cleanup even if signing fails
if (keypair.secret) {
const secretBuffer = Buffer.from(keypair.secret());
secretBuffer.fill(0);
}
}
}
45 changes: 25 additions & 20 deletions lib/stellar.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,35 @@
import { Keypair, TransactionBuilder } from "@stellar/stellar-sdk";
import { getSecureSigningKeypair, signTransactionSecurely } from "./keyManager";

/**
* Get a signing keypair with enhanced security features
*
* πŸ”’ SECURITY: Now uses SecureKeyManager for enhanced protection
* - Validates key format before processing
* - Uses constant-time comparison for public key validation
* - Automatic memory cleanup and key rotation
*
* @param expectedPublicKey Optional public key for validation
* @returns Stellar keypair with security enhancements
*/
export function getSigningKeypair(expectedPublicKey?: string): Keypair {
const secret = process.env.STELLAR_PRIVATE_KEY;

if (!secret) {
throw new Error("Missing STELLAR_PRIVATE_KEY");
}

const keypair = Keypair.fromSecret(secret);

if (expectedPublicKey && keypair.publicKey() !== expectedPublicKey) {
throw new Error(
"STELLAR_PRIVATE_KEY does not match the configured source public key"
);
}

return keypair;
return getSecureSigningKeypair(expectedPublicKey);
}

/**
* Sign a transaction with enhanced security
*
* πŸ”’ SECURITY: Uses secure key management and automatic cleanup
*
* @param txXDR Transaction XDR to sign
* @param networkPassphrase Network passphrase
* @param expectedPublicKey Optional public key validation
* @returns Signed transaction XDR
*/
export const signTransaction = (
txXDR: string,
networkPassphrase: string,
expectedPublicKey?: string
) => {
const keypair = getSigningKeypair(expectedPublicKey);
const transaction = TransactionBuilder.fromXDR(txXDR, networkPassphrase);
transaction.sign(keypair);
return transaction.toXDR();
): string => {
return signTransactionSecurely(txXDR, networkPassphrase, expectedPublicKey);
};
Loading