SessionStore.ts•6.43 kB
/**
* SessionStore - Encrypted Session Storage Manager
*
* This module handles secure storage and retrieval of WhaTap session data.
* Sessions are encrypted using AES-256-GCM before being stored to disk.
*/
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import * as crypto from 'crypto';
import type { Session, EncryptedSessionData } from '../types';
import { SessionError } from '../types';
/**
* SessionStore manages encrypted session persistence
*
* Features:
* - AES-256-GCM encryption
* - Automatic key generation
* - Session expiry checking
* - Atomic file operations
*/
export class SessionStore {
private configDir: string;
private sessionFile: string;
private keyFile: string;
private encryptionKey: Buffer | null = null;
constructor(configDir?: string) {
this.configDir = configDir || path.join(os.homedir(), '.whatap-mxql');
this.sessionFile = path.join(this.configDir, 'session.enc');
this.keyFile = path.join(this.configDir, '.key');
}
/**
* Save session data with encryption
*
* @param session - Session data to save
* @throws {SessionError} If encryption or file write fails
*/
async save(session: Session): Promise<void> {
try {
// Ensure config directory exists
await fs.mkdir(this.configDir, { recursive: true });
// Get or create encryption key
const key = await this.getOrCreateEncryptionKey();
// Serialize session
const sessionJson = JSON.stringify({
...session,
createdAt: session.createdAt.toISOString(),
expiresAt: session.expiresAt.toISOString(),
});
// Encrypt session data
const encrypted = this.encrypt(sessionJson, key);
// Write to file atomically (write to temp, then rename)
const tempFile = `${this.sessionFile}.tmp`;
await fs.writeFile(tempFile, JSON.stringify(encrypted), {
encoding: 'utf-8',
mode: 0o600, // Owner read/write only
});
await fs.rename(tempFile, this.sessionFile);
} catch (error) {
throw new SessionError(
`Failed to save session: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Load session data with decryption
*
* @returns Session data or null if not found or expired
* @throws {SessionError} If decryption fails
*/
async load(): Promise<Session | null> {
try {
// Check if session file exists
const encrypted = await fs.readFile(this.sessionFile, 'utf-8');
const encryptedData: EncryptedSessionData = JSON.parse(encrypted);
// Get encryption key
const key = await this.getOrCreateEncryptionKey();
// Decrypt session data
const decrypted = this.decrypt(encryptedData, key);
const sessionData = JSON.parse(decrypted);
// Parse dates
const session: Session = {
...sessionData,
createdAt: new Date(sessionData.createdAt),
expiresAt: new Date(sessionData.expiresAt),
};
// Check expiry
if (new Date(session.expiresAt) < new Date()) {
await this.clear();
return null;
}
return session;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null; // File doesn't exist
}
throw new SessionError(
`Failed to load session: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Clear session data
*/
async clear(): Promise<void> {
try {
await fs.unlink(this.sessionFile);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw new SessionError(
`Failed to clear session: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
}
/**
* Check if session file exists
*/
async exists(): Promise<boolean> {
try {
await fs.access(this.sessionFile);
return true;
} catch {
return false;
}
}
/**
* Encrypt data using AES-256-GCM
*
* @param text - Plain text to encrypt
* @param key - Encryption key (32 bytes)
* @returns Encrypted data with IV and auth tag
*/
private encrypt(text: string, key: Buffer): EncryptedSessionData {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return {
iv: iv.toString('hex'),
encrypted,
authTag: authTag.toString('hex'),
};
}
/**
* Decrypt data using AES-256-GCM
*
* @param data - Encrypted data with IV and auth tag
* @param key - Encryption key (32 bytes)
* @returns Decrypted plain text
*/
private decrypt(data: EncryptedSessionData, key: Buffer): string {
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
key,
Buffer.from(data.iv, 'hex')
);
decipher.setAuthTag(Buffer.from(data.authTag, 'hex'));
let decrypted = decipher.update(data.encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Get or create encryption key
*
* - If key file exists, load it
* - If not, generate new 256-bit key and save it
* - Key file has restricted permissions (0600)
*
* @returns 32-byte encryption key
*/
private async getOrCreateEncryptionKey(): Promise<Buffer> {
if (this.encryptionKey) {
return this.encryptionKey;
}
try {
// Try to load existing key
const keyData = await fs.readFile(this.keyFile);
this.encryptionKey = keyData;
return keyData;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
// Generate new key
const key = crypto.randomBytes(32);
// Ensure config directory exists
await fs.mkdir(this.configDir, { recursive: true });
// Save key with restricted permissions
await fs.writeFile(this.keyFile, key, { mode: 0o600 });
this.encryptionKey = key;
return key;
}
}
/**
* Get config directory path (for testing)
*/
getConfigDir(): string {
return this.configDir;
}
/**
* Get session file path (for testing)
*/
getSessionFilePath(): string {
return this.sessionFile;
}
}