feat: add session persistence, state management commands, and --new-tab click (#184)
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>
This commit is contained in:
co-authored by
Chris Tate
parent
cdd10ebb54
commit
697b788af0
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user