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!)
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
}
}
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);
}
}
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);
}
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);
}
});
}
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");
}
}
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 {}
}
}
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}"`);
}
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);
}
}
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;
}
}
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"
}
}
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();
}
}
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");
}
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");
}
}
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();
}
}
}
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);
}
}
}
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()
);
}
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
- Don't roll your own crypto unless necessary
- Use established libraries when possible
- Defense in depth is essential
- 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:
- libsodium-wrappers - Cryptographic operations
- proper-lockfile - File locking
- Node.js
crypto- AES encryption
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)