Rebased and fixed implementation of PR #184 features on current main: Session persistence: - --session-name flag and AGENT_BROWSER_SESSION_NAME env var auto-save/restore cookies and localStorage across browser restarts - State files stored in ~/.agent-browser/sessions/ with owner-only permissions - AES-256-GCM encryption via AGENT_BROWSER_ENCRYPTION_KEY env var - Auto-expiration of old state files (AGENT_BROWSER_STATE_EXPIRE_DAYS, default 30) State management commands: - state list: list saved state files with metadata - state show <file>: display state summary (cookies, origins, domains) - state rename <old> <new>: rename state files - state clear [name] [--all]: clear saved states - state clean --older-than <days>: delete expired states New --new-tab flag for click command: - Opens link href in a new tab instead of navigating the current tab Security hardening: - Session name validation prevents path traversal (CLI + daemon) - safeHeaderMerge prevents prototype pollution in header merging - WebSocket stream server binds to 127.0.0.1 only - State files written with 0o600 permissions Fixes applied over the original PR: - Use color.rs module instead of hardcoded ANSI escape codes - Align CLI output field names with daemon response format - Add CLI-level --session-name validation (not just daemon-side) - Avoid adding "DOM" to tsconfig.json lib (use proper typing in evaluate) - Keep version at 0.9.3 (matches current main) - Centralize session name validation in daemon.ts helper - Update all documentation (README, SKILL.md, docs site, --help output) Co-authored-by: Chris Tate <chris@ctate.dev>
112 lines
3.2 KiB
TypeScript
112 lines
3.2 KiB
TypeScript
/**
|
|
* Encryption utilities for state file protection using AES-256-GCM.
|
|
*/
|
|
|
|
import * as crypto from 'crypto';
|
|
|
|
// ============================================
|
|
// Constants
|
|
// ============================================
|
|
export const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
|
|
export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY';
|
|
export const IV_LENGTH = 12; // 96 bits for GCM
|
|
|
|
/**
|
|
* Encrypted payload structure.
|
|
*/
|
|
export interface EncryptedPayload {
|
|
version: 1;
|
|
encrypted: true;
|
|
iv: string; // Base64 encoded
|
|
authTag: string; // Base64 encoded
|
|
data: string; // Base64 encoded ciphertext
|
|
}
|
|
|
|
/**
|
|
* Get encryption key from environment variable.
|
|
* The key should be a 32-byte (256-bit) hex-encoded string (64 characters).
|
|
* Generate with: openssl rand -hex 32
|
|
*
|
|
* @returns Buffer containing the key, or null if not set/invalid
|
|
*/
|
|
export function getEncryptionKey(): Buffer | null {
|
|
const keyHex = process.env[ENCRYPTION_KEY_ENV];
|
|
if (!keyHex) return null;
|
|
|
|
// Key should be 64 hex chars = 32 bytes = 256 bits
|
|
if (!/^[a-fA-F0-9]{64}$/.test(keyHex)) {
|
|
console.warn(
|
|
`Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` +
|
|
`Generate one with: openssl rand -hex 32`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
return Buffer.from(keyHex, 'hex');
|
|
}
|
|
|
|
/**
|
|
* Encrypt data using AES-256-GCM.
|
|
* Returns a JSON-serializable payload with IV, auth tag, and encrypted data.
|
|
*
|
|
* @param plaintext - The string to encrypt
|
|
* @param key - The 256-bit encryption key
|
|
* @returns Encrypted payload object
|
|
*/
|
|
export function encryptData(plaintext: string, key: Buffer): EncryptedPayload {
|
|
const iv = crypto.randomBytes(IV_LENGTH);
|
|
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
|
|
|
let encrypted = cipher.update(plaintext, 'utf8');
|
|
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
|
|
return {
|
|
version: 1,
|
|
encrypted: true,
|
|
iv: iv.toString('base64'),
|
|
authTag: cipher.getAuthTag().toString('base64'),
|
|
data: encrypted.toString('base64'),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Decrypt data using AES-256-GCM.
|
|
*
|
|
* @param payload - The encrypted payload object
|
|
* @param key - The 256-bit encryption key
|
|
* @returns Decrypted plaintext string
|
|
* @throws Error if decryption fails (wrong key, tampered data, etc.)
|
|
*/
|
|
export function decryptData(payload: EncryptedPayload, key: Buffer): string {
|
|
const iv = Buffer.from(payload.iv, 'base64');
|
|
const authTag = Buffer.from(payload.authTag, 'base64');
|
|
const encryptedData = Buffer.from(payload.data, 'base64');
|
|
|
|
const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
|
|
decipher.setAuthTag(authTag);
|
|
|
|
let decrypted = decipher.update(encryptedData);
|
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
|
|
return decrypted.toString('utf8');
|
|
}
|
|
|
|
/**
|
|
* Check if a parsed JSON object is an encrypted payload.
|
|
*
|
|
* @param data - The object to check
|
|
* @returns True if the object is a valid encrypted payload
|
|
*/
|
|
export function isEncryptedPayload(data: unknown): data is EncryptedPayload {
|
|
return (
|
|
typeof data === 'object' &&
|
|
data !== null &&
|
|
'encrypted' in data &&
|
|
(data as EncryptedPayload).encrypted === true &&
|
|
'version' in data &&
|
|
'iv' in data &&
|
|
'authTag' in data &&
|
|
'data' in data
|
|
);
|
|
}
|