DEV Community

Jayant Hegde Kageri
Jayant Hegde Kageri

Posted on

Building an Encryption System: A Technical Deep Dive

How I built a production-grade encryption module in TypeScript for realm.rd


Introduction

Encryption is hard. Today, I'm sharing how I built the encryption system for realm.rd, a secure note-taking application. This isn't about theoryβ€”it's about real implementation challenges, design decisions, and the code that makes it work.

Tech stack: TypeScript, Node.js, libsodium, AES-256-CBC

Design Goals

  • Zero-knowledge encryption (I can't decrypt user data)
  • Cross-platform (Windows, macOS, Linux)
  • Auditable (every operation logged)
  • Performant (minimal overhead)

1. Secure Memory Management

The Problem

JavaScript's garbage collector is terrible for cryptography:

// Problem: Key lingers in memory
let key = "secret-key-12345";
key = null;  // GC cleans up... eventually (seconds later!)
Enter fullscreen mode Exit fullscreen mode

My Solution

Custom memory manager using libsodium:

class SecureMemoryManager {
  private static allocatedBuffers = new Map<Uint8Array, { size: number; timestamp: number }>();
  private static totalAllocatedSize = 0;

  static async allocate(size: number): Promise<Uint8Array> {
    // Check pool limits (10MB max)
    if (this.totalAllocatedSize + size > MAX_POOL_SIZE) {
      await this.cleanupStaleAllocations();
    }

    const buffer = new Uint8Array(size);
    this.allocatedBuffers.set(buffer, { size, timestamp: Date.now() });
    this.totalAllocatedSize += size;
    return buffer;
  }

  static async free(buffer: Uint8Array): Promise<void> {
    this.allocatedBuffers.delete(buffer);
    this.totalAllocatedSize -= buffer.length;
    sodium.memzero(buffer);  // Immediate secure zeroing
  }
}
Enter fullscreen mode Exit fullscreen mode

Impact: Reduced key exposure from seconds to microseconds.


2. Master Key System

The Challenge

Where do you store the master encryption key securely?

My Implementation

private async generateMasterKey(): Promise<Buffer> {
  const masterKey = await SecureMemoryManager.allocate(32);
  const nonce = await SecureMemoryManager.allocate(sodium.crypto_secretbox_NONCEBYTES);
  const storageKey = await SecureMemoryManager.allocate(sodium.crypto_secretbox_KEYBYTES);

  try {
    // Generate random keys
    masterKey.set(sodium.randombytes_buf(32));
    nonce.set(sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES));
    storageKey.set(sodium.randombytes_buf(sodium.crypto_secretbox_KEYBYTES));

    // Encrypt master key
    const encrypted = sodium.crypto_secretbox_easy(masterKey, nonce, storageKey);

    // Store: [nonce][storageKey][encrypted]
    return Buffer.concat([
      Buffer.from(nonce),
      Buffer.from(storageKey),
      Buffer.from(encrypted)
    ]);
  } finally {
    // Clean up immediately
    await SecureMemoryManager.free(masterKey);
    await SecureMemoryManager.free(nonce);
    await SecureMemoryManager.free(storageKey);
  }
}
Enter fullscreen mode Exit fullscreen mode

Storage: Encrypted file with platform-specific permissions (0600 on Unix, ACL restrictions on Windows).


3. Encryption Pipeline

HKDF Key Derivation

Instead of using the master key directly, I derive unique keys per operation using HKDF:

async function hkdfAsync(
  algorithm: string,
  masterKey: Buffer,
  salt: Buffer,
  info: Buffer,
  keyLength: number
): Promise<Buffer> {
  // HKDF Extract
  const prk = createHmac(algorithm, salt).update(masterKey).digest();

  // HKDF Expand
  let t = Buffer.alloc(0);
  let okm = Buffer.alloc(0);
  const iterations = Math.ceil(keyLength / 32);

  for (let i = 0; i < iterations; i++) {
    const hmac = createHmac(algorithm, prk);
    hmac.update(t);
    if (info.length > 0) hmac.update(info);
    hmac.update(Buffer.from([i + 1]));
    t = hmac.digest();
    okm = Buffer.concat([okm, t]);
  }

  MemoryUtils.secureZero(prk);
  MemoryUtils.secureZero(t);
  return okm.subarray(0, keyLength);
}
Enter fullscreen mode Exit fullscreen mode

Why HKDF?

  • Domain separation (different contexts = different keys)
  • Forward secrecy (one compromised key doesn't expose others)
  • NIST-approved standard

Encryption Flow

async encryptData(data: string): Promise<string> {
  return this.withMasterKey(async (masterKey) => {
    let derivedKey, iv, salt;

    try {
      // Generate random IV and salt
      iv = Buffer.from(sodium.randombytes_buf(16));
      salt = Buffer.from(sodium.randombytes_buf(32));

      // Derive unique encryption key
      const context = MemoryUtils.createContext("realm.encryption", "aes-256-cbc", 32);
      derivedKey = await this.hkdfKey(masterKey, salt, context, 32);

      // Encrypt
      const cipher = createCipheriv('aes-256-cbc', derivedKey, iv);
      let encrypted = cipher.update(data, 'utf8', 'hex');
      encrypted += cipher.final('hex');

      // Format: [salt:32][iv:16][ciphertext:variable]
      return Buffer.concat([salt, iv, Buffer.from(encrypted, 'hex')]).toString('hex');

    } finally {
      if (derivedKey) MemoryUtils.secureZero(derivedKey);
      if (iv) MemoryUtils.secureZero(iv);
      if (salt) MemoryUtils.secureZero(salt);
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Data format: [Salt: 32 bytes][IV: 16 bytes][Ciphertext: variable]


4. Cross-Platform File Security

Platform-Specific Permissions

private getAppDataDirectory(): string {
  const homeDir = os.homedir();

  switch (process.platform) {
    case "win32":
      return path.join(homeDir, "AppData", "Local", "realm");
    case "darwin":
      return path.join(homeDir, "Library", "Application Support", "realm");
    default: // Linux
      return path.join(homeDir, ".config", "realm");
  }
}
Enter fullscreen mode Exit fullscreen mode

Unix Hardening

private async setUnixSecurePermissions(filePath: string): Promise<void> {
  await fs.chmod(filePath, 0o600);  // -rw-------

  if (process.platform === "linux") {
    try {
      // Make immutable
      await execAsync(`chattr +i "${filePath}"`).catch(() => {});

      // Extended attributes
      await execAsync(`setfattr -n security.realm -v "protected" "${filePath}"`).catch(() => {});
    } catch {}
  }
}
Enter fullscreen mode Exit fullscreen mode

Windows Hardening

private async setWindowsSecurePermissions(filePath: string): Promise<void> {
  const powershellScript = `
    $acl = Get-Acl "${filePath}"
    $acl.SetAccessRuleProtection($true, $false)
    $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
      $env:USERNAME, "FullControl", "Allow"
    )
    $acl.SetAccessRule($rule)
    Set-Acl "${filePath}" $acl

    # Hide file
    $file = Get-Item "${filePath}" -Force
    $file.Attributes = $file.Attributes -bor [System.IO.FileAttributes]::Hidden
  `;

  await execAsync(`powershell -Command "${powershellScript}"`);
}
Enter fullscreen mode Exit fullscreen mode

File Locking

async withFileLock<T>(filePath: string, operation: () => Promise<T>): Promise<T> {
  const lockOptions = {
    retries: { retries: 5, factor: 2, minTimeout: 100, maxTimeout: 2000 },
    stale: 30000,
    onCompromised: (err) => {
      this.auditLogger.logSecurityEvent("FILE_LOCK_COMPROMISED", "CRITICAL");
      throw new FileSystemError("Lock compromised");
    }
  };

  await lock(filePath, lockOptions);
  try {
    return await operation();
  } finally {
    await unlock(filePath, lockOptions);
  }
}
Enter fullscreen mode Exit fullscreen mode

5. Security Audit Logging

Implementation

class SecurityAuditLogger {
  private static instance: SecurityAuditLogger | null = null;
  private logPath: string;

  async logSecurityEvent(
    event: string,
    severity: "INFO" | "WARN" | "ERROR" | "CRITICAL",
    details: Record<string, unknown> = {}
  ): Promise<void> {
    const logEntry = {
      timestamp: new Date().toISOString(),
      event,
      severity,
      user: process.env.USER || "unknown",
      pid: process.pid,
      details: this.sanitizeLogData(details)
    };

    await fs.appendFile(this.logPath, JSON.stringify(logEntry) + "\n", { mode: 0o600 });
  }

  private sanitizeLogData(data: Record<string, unknown>): Record<string, unknown> {
    const sanitized: Record<string, unknown> = {};

    for (const [key, value] of Object.entries(data)) {
      if (key.toLowerCase().includes("key") || 
          key.toLowerCase().includes("password") ||
          key.toLowerCase().includes("secret")) {
        sanitized[key] = "[REDACTED]";
      } else {
        sanitized[key] = value;
      }
    }

    return sanitized;
  }
}
Enter fullscreen mode Exit fullscreen mode

Example log:

{
  "timestamp": "2026-02-06T10:30:45.123Z",
  "event": "DATA_ENCRYPTED",
  "severity": "INFO",
  "user": "jayant",
  "pid": 12345,
  "details": {
    "dataSize": 2048,
    "algorithm": "aes-256-cbc"
  }
}
Enter fullscreen mode Exit fullscreen mode

6. Error Handling & Security

Custom Error Types

class CryptoError extends Error {
  public readonly code: string;
  public readonly timestamp: Date;

  constructor(message: string, code: string = "CRYPTO_ERROR") {
    super(message);
    this.name = "CryptoError";
    this.code = code;
    this.timestamp = new Date();
  }
}
Enter fullscreen mode Exit fullscreen mode

Timing Attack Prevention

static async constantTimeDelay(baseDelayMs: number = 50): Promise<void> {
  const jitter = Math.floor(Math.random() * 20);
  await new Promise(resolve => setTimeout(resolve, baseDelayMs + jitter));
}

// Usage in decryption
try {
  return await decrypt(data);
} catch (error) {
  await MemoryUtils.constantTimeDelay();  // Same delay for all errors
  throw new CryptoError("Decryption failed");
}
Enter fullscreen mode Exit fullscreen mode

Input Validation

class InputValidator {
  private static readonly DANGEROUS_PATTERNS = [
    /\0/g,          // Null bytes
    /\.\./g,        // Path traversal
    /[<>"|*?]/g,    // Shell metacharacters
  ];

  static validateFilePath(filePath: string): string {
    if (filePath.length > 4096)
      throw new FileSystemError("Path too long");

    for (const pattern of this.DANGEROUS_PATTERNS) {
      if (pattern.test(filePath))
        throw new FileSystemError("Dangerous pattern in path");
    }

    const normalized = path.resolve(filePath);
    if (!normalized.startsWith(os.homedir()))
      throw new FileSystemError("Path outside allowed directories");

    return normalized;
  }

  static validateDataSize(data: string | Buffer): void {
    const size = typeof data === "string" 
      ? Buffer.byteLength(data, "utf8") 
      : data.length;

    if (size > 100 * 1024 * 1024)  // 100MB
      throw new CryptoError("Data too large");
  }
}
Enter fullscreen mode Exit fullscreen mode

Optimisations Implemented

1. Async Mutex for concurrency:

class AsyncMutex {
  private locked = false;
  private waitQueue: Array<() => void> = [];

  async runExclusive<T>(fn: () => Promise<T>): Promise<T> {
    while (this.locked) {
      await new Promise<void>((resolve) => this.waitQueue.push(resolve));
    }

    this.locked = true;
    try {
      return await fn();
    } finally {
      this.locked = false;
      const next = this.waitQueue.shift();
      if (next) next();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Stale memory cleanup:

private static async cleanupStaleAllocations(): Promise<void> {
  const now = Date.now();
  const staleThreshold = 5 * 60 * 1000;  // 5 minutes

  for (const [buffer, allocation] of this.allocatedBuffers.entries()) {
    if (now - allocation.timestamp > staleThreshold) {
      await this.free(buffer);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

7. Graceful Shutdown

process.once("SIGTERM", async () => {
  await CryptoManager.shutdown();
  process.exit(0);
});

process.on("uncaughtException", (error) => {
  SecurityAuditLogger.getInstance().logSecurityEvent(
    "UNCAUGHT_EXCEPTION",
    "CRITICAL",
    { error: error.message }
  );

  CryptoManager.shutdown()
    .then(() => process.exit(1))
    .catch(() => process.exit(1));
});

static async shutdown(): Promise<void> {
  // Stop timers
  if (this.keyRotationTimer) {
    clearTimeout(this.keyRotationTimer);
  }

  // Clean all memory
  await SecureMemoryManager.emergencyCleanup();

  // Final log
  SecurityAuditLogger.getInstance().logSecurityEvent(
    "CRYPTO_MANAGER_SHUTDOWN",
    "INFO",
    SecureMemoryManager.getStats()
  );
}
Enter fullscreen mode Exit fullscreen mode

Key Learnings

What Worked

  • Layered security - Multiple defence layers provide resilience
  • Secure memory management - libsodium integration is excellent
  • Comprehensive logging - Invaluable for debugging
  • Cross-platform support - Consistent experience across OSes

Challenges

  • Complexity - 1,300+ lines became hard to maintain
  • Platform differences: Windows vs. Unix security models are vastly different.
  • Performance tradeoffs - Security features add latency

Conclusion

Building this encryption system taught me that security is incredibly nuanced. Even with:

  • Modern crypto primitives (AES-256, HKDF, libsodium)
  • Multiple security layers
  • 1,300+ lines of defensive code

...perfection is impossible. Security is a continuous process of learning and improvement.

Key Takeaways

  1. Don't roll your own crypto unless necessary
  2. Use established libraries when possible
  3. Defense in depth is essential
  4. Security is a process, not a feature

Acknowledgments

Special recognition to my mentor and senior, Swanit Anuran, who:

  • Provided invaluable code reviews and security insights
  • Guided me through cryptographic best practices
  • Helped debug complex concurrency issues
  • Shared his deep expertise in security architecture

Resources

Libraries:

Standards:

  • NIST SP 800-56C (HKDF)
  • RFC 5869 (HKDF spec)

Project: realm.rd on GitHub


Built with care for the realm.rd - a secure note-taking application. Licensed under GNU AGPL v3.0.

Top comments (0)