Skip to main content

Encryption System

Learn how Dashtray securely stores sensitive data like API keys and credentials.

Overview

Dashtray uses AES-256-GCM encryption to protect sensitive data at rest. All API keys, OAuth tokens, and integration credentials are encrypted before being stored in the database.

Encryption Implementation

Location

src/convex/lib/encryption.ts

Algorithm

  • Cipher: AES-256-GCM (Galois/Counter Mode)
  • Key Derivation: PBKDF2 with SHA-256
  • Iterations: 100,000
  • IV: 12 bytes (randomly generated per encryption)
  • Authentication: Built-in with GCM mode

Why AES-256-GCM?

  • Strong encryption: 256-bit key size
  • Authenticated: Prevents tampering
  • Fast: Hardware-accelerated on modern CPUs
  • Standard: NIST-approved, widely used
  • Secure: No known practical attacks

Code Implementation

// src/convex/lib/encryption.ts
import { webcrypto } from 'crypto';

const MASTER_KEY = process.env.MASTER_ENCRYPTION_KEY!;

if (!MASTER_KEY || MASTER_KEY.length < 32) {
  throw new Error('MASTER_ENCRYPTION_KEY must be at least 32 characters');
}

/**
 * Encrypt sensitive data using AES-256-GCM
 *
 * @param data - Plain text data to encrypt
 * @param salt - Unique salt (typically userId or projectId)
 * @returns Base64-encoded encrypted data (IV + ciphertext + auth tag)
 */
export async function encrypt(data: string, salt: string): Promise<string> {
  const encoder = new TextEncoder();

  // Derive encryption key from master key + salt
  const keyMaterial = await webcrypto.subtle.importKey(
    'raw',
    encoder.encode(MASTER_KEY + salt),
    { name: 'PBKDF2' },
    false,
    ['deriveBits', 'deriveKey']
  );

  const key = await webcrypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: encoder.encode(salt),
      iterations: 100000,
      hash: 'SHA-256'
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt']
  );

  // Generate random IV (12 bytes for GCM)
  const iv = webcrypto.getRandomValues(new Uint8Array(12));

  // Encrypt data
  const encrypted = await webcrypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    encoder.encode(data)
  );

  // Combine IV + encrypted data
  const combined = new Uint8Array(iv.length + encrypted.byteLength);
  combined.set(iv);
  combined.set(new Uint8Array(encrypted), iv.length);

  // Return as base64
  return Buffer.from(combined).toString('base64');
}

/**
 * Decrypt data encrypted with encrypt()
 *
 * @param encryptedData - Base64-encoded encrypted data
 * @param salt - Same salt used for encryption
 * @returns Decrypted plain text
 */
export async function decrypt(encryptedData: string, salt: string): Promise<string> {
  const encoder = new TextEncoder();
  const decoder = new TextDecoder();

  // Decode from base64
  const combined = Buffer.from(encryptedData, 'base64');

  // Extract IV (first 12 bytes)
  const iv = combined.slice(0, 12);
  const encrypted = combined.slice(12);

  // Derive same encryption key
  const keyMaterial = await webcrypto.subtle.importKey(
    'raw',
    encoder.encode(MASTER_KEY + salt),
    { name: 'PBKDF2' },
    false,
    ['deriveBits', 'deriveKey']
  );

  const key = await webcrypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: encoder.encode(salt),
      iterations: 100000,
      hash: 'SHA-256'
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['decrypt']
  );

  // Decrypt data
  const decrypted = await webcrypto.subtle.decrypt(
    { name: 'AES-GCM', iv },
    key,
    encrypted
  );

  return decoder.decode(decrypted);
}

/**
 * Hash data using SHA-256 (for API keys)
 *
 * @param data - Data to hash
 * @returns Hex-encoded hash
 */
export async function hash(data: string): Promise<string> {
  const encoder = new TextEncoder();
  const hashBuffer = await webcrypto.subtle.digest(
    'SHA-256',
    encoder.encode(data)
  );

  return Buffer.from(hashBuffer).toString('hex');
}

Usage Examples

Encrypting Integration Credentials

// src/convex/connections.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';
import { encrypt } from './lib/encryption';

export const create = mutation({
  args: {
    projectId: v.id('projects'),
    service: v.string(),
    credentials: v.any()
  },
  handler: async (ctx, args) => {
    // Encrypt credentials using projectId as salt
    const encryptedCredentials = await encrypt(
      JSON.stringify(args.credentials),
      args.projectId
    );

    // Store encrypted credentials
    const connectionId = await ctx.db.insert('connections', {
      projectId: args.projectId,
      service: args.service,
      encryptedCredentials,
      status: 'active',
      healthScore: 100,
      createdAt: Date.now(),
      updatedAt: Date.now()
    });

    return connectionId;
  }
});

Decrypting Credentials for API Calls

// src/convex/connections.ts
import { action } from './_generated/server';
import { v } from 'convex/values';
import { decrypt } from './lib/encryption';

export const syncMetrics = action({
  args: {
    connectionId: v.id('connections')
  },
  handler: async (ctx, args) => {
    // Get connection from database
    const connection = await ctx.runQuery(
      internal.connections.get,
      { connectionId: args.connectionId }
    );

    // Decrypt credentials
    const credentials = JSON.parse(
      await decrypt(connection.encryptedCredentials, connection.projectId)
    );

    // Use credentials to fetch metrics
    const integration = getIntegration(connection.service);
    const metrics = await integration.fetchMetrics(credentials, {
      metricIds: ['all'],
      startDate: new Date(Date.now() - 24 * 60 * 60 * 1000),
      endDate: new Date()
    });

    // Store metrics...
  }
});

Encrypting AI API Keys

// src/convex/auth.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';
import { encrypt } from './lib/encryption';

export const updateAIKey = mutation({
  args: {
    provider: v.union(v.literal('openai'), v.literal('anthropic')),
    apiKey: v.string()
  },
  handler: async (ctx, args) => {
    const userId = await getCurrentUserId(ctx);

    // Encrypt API key using userId as salt
    const encryptedKey = await encrypt(args.apiKey, userId);

    // Store encrypted key
    await ctx.db.patch(userId, {
      aiApiKey: encryptedKey,
      aiProvider: args.provider,
      updatedAt: Date.now()
    });
  }
});

Hashing API Keys

// src/convex/apiKeys.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';
import { hash } from './lib/encryption';
import { generateApiKey } from './lib/utils';

export const create = mutation({
  args: {
    projectId: v.id('projects'),
    name: v.string()
  },
  handler: async (ctx, args) => {
    const userId = await getCurrentUserId(ctx);

    // Generate API key
    const apiKey = generateApiKey(); // e.g., "pp_live_abc123..."

    // Hash the key for storage
    const keyHash = await hash(apiKey);

    // Store hash and prefix
    const apiKeyId = await ctx.db.insert('apiKeys', {
      projectId: args.projectId,
      name: args.name,
      keyHash,
      keyPrefix: apiKey.substring(0, 8), // For display
      createdBy: userId,
      createdAt: Date.now()
    });

    // Return the actual key (only time it's visible)
    return { apiKeyId, apiKey };
  }
});

Security Considerations

Master Key Management

The master encryption key is stored in an environment variable:
# Generate a secure master key
openssl rand -hex 32

# Set in Convex
npx convex env set MASTER_ENCRYPTION_KEY your_key_here
Important:
  • Never commit the master key to version control
  • Use different keys for development and production
  • Rotate the key periodically (requires re-encryption)
  • Store backups securely (encrypted)

Salt Usage

Salts prevent rainbow table attacks and ensure unique ciphertexts:
  • Project credentials: Use projectId as salt
  • User AI keys: Use userId as salt
  • API keys: Use SHA-256 hash (no encryption needed)

Key Rotation

To rotate the master encryption key:
  1. Generate new master key
  2. Decrypt all data with old key
  3. Re-encrypt with new key
  4. Update environment variable
  5. Deploy changes
// Migration script (run once)
export const rotateEncryptionKey = internalMutation({
  handler: async (ctx) => {
    const OLD_KEY = process.env.OLD_MASTER_KEY!;
    const NEW_KEY = process.env.MASTER_ENCRYPTION_KEY!;

    // Get all connections
    const connections = await ctx.db.query('connections').collect();

    for (const connection of connections) {
      // Decrypt with old key
      const credentials = await decryptWithKey(
        connection.encryptedCredentials,
        connection.projectId,
        OLD_KEY
      );

      // Re-encrypt with new key
      const newEncrypted = await encryptWithKey(
        credentials,
        connection.projectId,
        NEW_KEY
      );

      // Update database
      await ctx.db.patch(connection._id, {
        encryptedCredentials: newEncrypted
      });
    }

    // Repeat for userSettings, etc.
  }
});

Testing

Unit Tests

// src/convex/lib/encryption.test.ts
import { describe, it, expect } from 'vitest';
import { encrypt, decrypt, hash } from './encryption';

describe('Encryption', () => {
  it('should encrypt and decrypt data', async () => {
    const plaintext = 'sensitive data';
    const salt = 'user_123';

    const encrypted = await encrypt(plaintext, salt);
    const decrypted = await decrypt(encrypted, salt);

    expect(decrypted).toBe(plaintext);
  });

  it('should produce different ciphertexts for same data', async () => {
    const plaintext = 'sensitive data';
    const salt = 'user_123';

    const encrypted1 = await encrypt(plaintext, salt);
    const encrypted2 = await encrypt(plaintext, salt);

    // Different IVs = different ciphertexts
    expect(encrypted1).not.toBe(encrypted2);

    // But both decrypt to same plaintext
    expect(await decrypt(encrypted1, salt)).toBe(plaintext);
    expect(await decrypt(encrypted2, salt)).toBe(plaintext);
  });

  it('should fail with wrong salt', async () => {
    const plaintext = 'sensitive data';
    const salt1 = 'user_123';
    const salt2 = 'user_456';

    const encrypted = await encrypt(plaintext, salt1);

    await expect(decrypt(encrypted, salt2)).rejects.toThrow();
  });

  it('should hash consistently', async () => {
    const data = 'api_key_123';

    const hash1 = await hash(data);
    const hash2 = await hash(data);

    expect(hash1).toBe(hash2);
    expect(hash1).toHaveLength(64); // SHA-256 = 32 bytes = 64 hex chars
  });
});

Performance

Benchmarks

Typical performance on modern hardware:
  • Encryption: ~1ms per operation
  • Decryption: ~1ms per operation
  • Hashing: ~0.1ms per operation

Optimization Tips

  1. Batch operations: Encrypt/decrypt multiple items together
  2. Cache decrypted data: Don’t decrypt repeatedly in same request
  3. Use hashing for lookups: Hash is faster than encryption
  4. Async operations: Encryption is async, use Promise.all() for batches
// Bad: Sequential
for (const connection of connections) {
  const creds = await decrypt(connection.encryptedCredentials, connection.projectId);
  // Use creds...
}

// Good: Parallel
const decryptedCreds = await Promise.all(
  connections.map(conn =>
    decrypt(conn.encryptedCredentials, conn.projectId)
  )
);

Compliance

GDPR

  • Encryption at rest protects personal data
  • Users can request data deletion
  • Encryption keys are not shared

SOC 2

  • Industry-standard encryption (AES-256)
  • Key management procedures
  • Access controls

PCI DSS

  • Not storing credit card data (handled by Stripe/DodoPayments)
  • API keys encrypted at rest
  • Secure key management

Troubleshooting

”Decryption failed”

Causes:
  • Wrong salt used
  • Master key changed
  • Data corrupted
  • Wrong encryption version
Solutions:
  1. Verify salt matches encryption
  2. Check master key is correct
  3. Re-encrypt data if key rotated
  4. Check for data corruption

”Master key not set”

Cause: Environment variable not configured Solution:
npx convex env set MASTER_ENCRYPTION_KEY $(openssl rand -hex 32)

Performance Issues

Cause: Encrypting/decrypting too frequently Solutions:
  1. Cache decrypted credentials
  2. Batch operations
  3. Use connection pooling

Best Practices

  1. Never log decrypted data: Always log encrypted or masked versions
  2. Use appropriate salts: Project-specific or user-specific
  3. Rotate keys periodically: Plan for key rotation
  4. Test encryption: Verify encrypt/decrypt roundtrip
  5. Monitor performance: Track encryption overhead
  6. Document key management: Maintain key rotation procedures
  7. Backup encrypted data: Encrypted backups are safe
  8. Use HTTPS: Encryption at rest + encryption in transit

Need Help?