diff --git a/CLAWDBOT-SECURITY-PLAN.md b/CLAWDBOT-SECURITY-PLAN.md new file mode 100644 index 000000000..a7ea03ae2 --- /dev/null +++ b/CLAWDBOT-SECURITY-PLAN.md @@ -0,0 +1,240 @@ +# Clawdbot Security Hardening Plan - Phase 1 + +**Location:** This file lives in the security worktree at `~/clawdbot-security` +**Branch:** `phase1-security` +**Purpose:** Tracks progress on Phase 1 Security Hardening tasks. Other Claude Code sessions can pick up incomplete tasks. + +--- + +## Worktree Setup + +Git worktrees are set up for parallel development: + +```bash +~/clawdbot-analysis/ # main branch (original clone) +~/clawdbot-security/ # phase1-security branch (THIS WORKTREE) +``` + +To create additional worktrees for other phases: +```bash +cd ~/clawdbot-analysis +git worktree add ../clawdbot-architecture -b phase2-architecture +git worktree add ../clawdbot-ux -b phase3-ux +``` + +--- + +## Phase 1 Task Status + +### ✅ COMPLETED + +#### 1.1 Prompt Injection Defense +- **Files Modified:** + - `src/gateway/chat-sanitize.ts` - Added 300+ lines of injection detection + - `src/gateway/chat-sanitize.test.ts` - 38 tests +- **Features:** + - Regex-based jailbreak detection with tiered severity (critical/high/medium/low) + - Prompt boundary markers (`[USER_INPUT_START]`/`[USER_INPUT_END]`) + - Configurable blocking, logging, and warning features + - Patterns: ignore instructions, DAN/jailbreak, system prompt extraction, role manipulation +- **Commit:** `77c93a8dc` "security: add prompt injection defense system" + +#### 1.2 Command Execution Blocklist +- **Files Created/Modified:** + - `src/infra/exec-blocklist.ts` - New blocklist module (~400 lines) + - `src/infra/exec-blocklist.test.ts` - 47 tests + - `src/infra/exec-approvals.ts` - Integrated blocklist check + - `src/infra/exec-approvals.test.ts` - Updated for blocklist behavior +- **Features:** + - Critical: rm -rf /, dd to disk, mkfs, halt/reboot, fork bombs (ALWAYS BLOCKED) + - High: sudo, passwd, visudo, iptables, user management (BLOCKED, requires explicit approval) + - Medium: command substitution, eval, curl POST (WARNED, allowed by default) + - Blocklist is checked BEFORE allowlist evaluation +- **Commit:** `33eeb033c` "security: add command execution blocklist" + +#### 1.3 Secrets Manager (Core + Integration) +- **Files Created/Modified:** + - `src/infra/secrets-manager.ts` - Cross-platform secrets manager (~700 lines) + - `src/infra/secrets-manager.test.ts` - 27 tests + - `src/discord/token.ts` - Added secrets as Priority 1 source + - `src/discord/token.test.ts` - Updated tests with `skipSecrets` option + - `src/discord/accounts.ts` - Updated type to include "secrets" source + - `src/telegram/token.ts` - Added secrets as Priority 1 source + - `src/telegram/token.test.ts` - Updated tests with `skipSecrets` option + - `src/telegram/accounts.ts` - Updated type to include "secrets" source + - `src/slack/accounts.ts` - Added secrets as Priority 1 source with `skipSecrets` option + - `src/web/auth-store.ts` - Added encrypted credentials support for WhatsApp +- **Features:** + - macOS Keychain via `security` command + - Linux Secret Service via `secret-tool` (libsecret) + - Fallback to AES-256-GCM encrypted files with PBKDF2 key derivation + - Machine-derived encryption key for file fallback + - Utilities: generateSecureToken, hashSecret, verifySecretHash + - File encryption utilities: encryptFile, decryptFile, readPossiblyEncryptedFile + - WhatsApp creds.json now supports .enc encrypted format + - Migration helper: migrateWebCredsToEncrypted() for WhatsApp + - All token resolution functions now check secrets-manager first +- **Commits:** + - `a6f48ac29` "security: add cross-platform secrets manager" + - TBD "security: integrate secrets-manager with all token stores" +- **TODO:** + - Add migration CLI command: `clawdbot secrets migrate` + - Update `clawdbot doctor` to check for unencrypted credentials + +--- + +### ⏳ PENDING + +#### 1.4 Gateway Authentication & Rate Limiting +**Objective:** Enforce authentication and add configurable rate limiting. + +**Files to Modify:** +- `src/gateway/auth.ts` - Enforce token+password for non-loopback bindings +- `src/gateway/server.impl.ts` - Add startup warning for public binding +- Create `src/gateway/rate-limit.ts` - Configurable rate limiting + +**Implementation Spec:** +```typescript +// Rate limit defaults (configurable in clawdbot.json) +{ + "gateway": { + "rateLimits": { + "unauthenticated": 60, // req/min (prevent brute-force) + "authenticated": 0, // 0 = unlimited (power user flexibility) + "channelMessages": 200, // per channel per minute + "burstMultiplier": 2 // allow 2x burst for short spikes + } + } +} +``` + +**Key Behaviors:** +- Exponential backoff ONLY after failed auth (not normal requests) +- Log warning at startup if binding to non-loopback without auth +- Allow authenticated users to remain unlimited by default + +#### 1.5 Pairing & Approval Hardening +**Objective:** Increase entropy and add replay protection. + +**Files to Modify:** +- `src/pairing/pairing-store.ts` + - Increase pairing code from 8 chars (~40 bits) to 16 chars (~80 bits) + - Add rate limiting on pairing attempts (1/sec max) + - Sign pairing store with HMAC +- `src/infra/exec-approvals.ts` + - Implement one-time-use nonces for approval tokens + - Add nonce tracking to prevent replay attacks + +**Current Pairing Code:** +- 8 chars, alphabet: `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (32 chars = 5 bits each) +- Total entropy: ~40 bits (too low for security-sensitive use) + +**Target:** +- 16 chars = ~80 bits entropy +- Add HMAC signature to pairing-store.json +- Rate limit: 1 attempt/second per IP/session + +#### 1.6 Security Documentation +**Objective:** Create formal security documentation. + +**Files to Create:** +- `docs/security/threat-model.md` - Cover channels, tools, browser, local files +- `docs/security/data-handling.md` - Retention, logs, consent, export/delete +- `docs/security/security-posture.md` - Public-facing security overview + +**Files to Modify:** +- `src/commands/doctor.ts` - Add security audit checks + - Detect unencrypted credentials + - Check for public gateway binding without auth + - Verify pairing code entropy + +--- + +## How to Continue This Work + +### For a New Claude Code Session: + +1. **Navigate to the worktree:** + ```bash + cd ~/clawdbot-security + ``` + +2. **Read this plan file:** + ```bash + cat CLAWDBOT-SECURITY-PLAN.md + ``` + +3. **Check current branch and status:** + ```bash + git log --oneline -5 + git status + ``` + +4. **Pick an IN PROGRESS or PENDING task and continue.** + +5. **Follow commit conventions:** + ```bash + git commit -m "security: + + Co-Authored-By: Claude Opus 4.5 " + ``` + +### Parallel Work Guidelines: +- Each task modifies different files, so parallel work is safe +- If touching the same file, coordinate or work sequentially +- Run `npx vitest run ` after changes to verify tests pass + +--- + +## Testing Commands + +```bash +# Run specific test file +npx vitest run src/gateway/chat-sanitize.test.ts +npx vitest run src/infra/exec-blocklist.test.ts +npx vitest run src/infra/secrets-manager.test.ts + +# Run all tests +pnpm test + +# Type check +pnpm build +``` + +--- + +## Files Changed Summary + +| Phase | File | Status | Lines Changed | +|-------|------|--------|---------------| +| 1.1 | src/gateway/chat-sanitize.ts | ✅ | +320 | +| 1.1 | src/gateway/chat-sanitize.test.ts | ✅ | +200 | +| 1.2 | src/infra/exec-blocklist.ts | ✅ | +450 | +| 1.2 | src/infra/exec-blocklist.test.ts | ✅ | +250 | +| 1.2 | src/infra/exec-approvals.ts | ✅ | +50 | +| 1.3 | src/infra/secrets-manager.ts | ✅ | +700 | +| 1.3 | src/infra/secrets-manager.test.ts | ✅ | +270 | +| 1.3 | src/discord/token.ts | ✅ | +25 | +| 1.3 | src/discord/token.test.ts | ✅ | +10 | +| 1.3 | src/discord/accounts.ts | ✅ | +5 | +| 1.3 | src/telegram/token.ts | ✅ | +30 | +| 1.3 | src/telegram/token.test.ts | ✅ | +10 | +| 1.3 | src/telegram/accounts.ts | ✅ | +5 | +| 1.3 | src/slack/accounts.ts | ✅ | +40 | +| 1.3 | src/web/auth-store.ts | ✅ | +120 | +| 1.4 | src/gateway/auth.ts | ⏳ | TBD | +| 1.4 | src/gateway/rate-limit.ts | ⏳ | TBD | +| 1.5 | src/pairing/pairing-store.ts | ⏳ | TBD | +| 1.6 | docs/security/*.md | ⏳ | TBD | + +--- + +## Contact / Context + +- **Original Plan:** `/Users/ronitchidara/Desktop/clawdbot-production-readiness-analysis.md` +- **Main Codebase:** `~/clawdbot-analysis` +- **Security Worktree:** `~/clawdbot-security` (this location) + +--- + +*Last updated: 2026-01-27 by Claude Opus 4.5* diff --git a/src/discord/accounts.ts b/src/discord/accounts.ts index 7964a2a4e..2b890f867 100644 --- a/src/discord/accounts.ts +++ b/src/discord/accounts.ts @@ -1,14 +1,14 @@ import type { MoltbotConfig } from "../config/config.js"; import type { DiscordAccountConfig } from "../config/types.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveDiscordToken } from "./token.js"; +import { resolveDiscordToken, type DiscordTokenSource } from "./token.js"; export type ResolvedDiscordAccount = { accountId: string; enabled: boolean; name?: string; token: string; - tokenSource: "env" | "config" | "none"; + tokenSource: DiscordTokenSource; config: DiscordAccountConfig; }; diff --git a/src/discord/token.test.ts b/src/discord/token.test.ts index 7110342fb..f1045e410 100644 --- a/src/discord/token.test.ts +++ b/src/discord/token.test.ts @@ -13,7 +13,8 @@ describe("resolveDiscordToken", () => { const cfg = { channels: { discord: { token: "cfg-token" } }, } as MoltbotConfig; - const res = resolveDiscordToken(cfg); + // Skip secrets to test config/env precedence in isolation + const res = resolveDiscordToken(cfg, { skipSecrets: true }); expect(res.token).toBe("cfg-token"); expect(res.source).toBe("config"); }); @@ -23,7 +24,8 @@ describe("resolveDiscordToken", () => { const cfg = { channels: { discord: {} }, } as MoltbotConfig; - const res = resolveDiscordToken(cfg); + // Skip secrets to test config/env precedence in isolation + const res = resolveDiscordToken(cfg, { skipSecrets: true }); expect(res.token).toBe("env-token"); expect(res.source).toBe("env"); }); @@ -40,8 +42,18 @@ describe("resolveDiscordToken", () => { }, }, } as MoltbotConfig; - const res = resolveDiscordToken(cfg, { accountId: "work" }); + // Skip secrets to test config/env precedence in isolation + const res = resolveDiscordToken(cfg, { accountId: "work", skipSecrets: true }); expect(res.token).toBe("acct-token"); expect(res.source).toBe("config"); }); + + it("returns source=none when no token is available", () => { + const cfg = { + channels: { discord: {} }, + } as MoltbotConfig; + const res = resolveDiscordToken(cfg, { skipSecrets: true }); + expect(res.token).toBe(""); + expect(res.source).toBe("none"); + }); }); diff --git a/src/discord/token.ts b/src/discord/token.ts index 89b7fa656..2a41f30a9 100644 --- a/src/discord/token.ts +++ b/src/discord/token.ts @@ -1,7 +1,8 @@ import type { MoltbotConfig } from "../config/config.js"; +import { getSecret } from "../infra/secrets-manager.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -export type DiscordTokenSource = "env" | "config" | "none"; +export type DiscordTokenSource = "env" | "config" | "secrets" | "none"; export type DiscordTokenResolution = { token: string; @@ -15,11 +16,36 @@ export function normalizeDiscordToken(raw?: string | null): string | undefined { return trimmed.replace(/^Bot\s+/i, ""); } +/** + * Resolves the secret key name for a Discord token. + */ +function getDiscordSecretKey(accountId: string): string { + return `discord-token-${accountId}`; +} + +/** + * Resolves Discord bot token from multiple sources in priority order: + * 1. Secure secrets storage (keychain/encrypted file) + * 2. Config file (channels.discord.accounts[accountId].token) + * 3. Environment variable (DISCORD_BOT_TOKEN) for default account only + */ export function resolveDiscordToken( cfg?: MoltbotConfig, - opts: { accountId?: string | null; envToken?: string | null } = {}, + opts: { accountId?: string | null; envToken?: string | null; skipSecrets?: boolean } = {}, ): DiscordTokenResolution { const accountId = normalizeAccountId(opts.accountId); + + // Priority 1: Check secure secrets storage first (unless explicitly skipped) + if (!opts.skipSecrets) { + const secretKey = getDiscordSecretKey(accountId); + const secretResult = getSecret(secretKey); + if (secretResult.success && secretResult.value) { + const secretToken = normalizeDiscordToken(secretResult.value); + if (secretToken) return { token: secretToken, source: "secrets" }; + } + } + + // Priority 2: Check config file const discordCfg = cfg?.channels?.discord; const accountCfg = accountId !== DEFAULT_ACCOUNT_ID @@ -32,6 +58,7 @@ export function resolveDiscordToken( const configToken = allowEnv ? normalizeDiscordToken(discordCfg?.token ?? undefined) : undefined; if (configToken) return { token: configToken, source: "config" }; + // Priority 3: Check environment variable (default account only) const envToken = allowEnv ? normalizeDiscordToken(opts.envToken ?? process.env.DISCORD_BOT_TOKEN) : undefined; diff --git a/src/infra/secrets-manager.ts b/src/infra/secrets-manager.ts index e7021f5ae..644320fd4 100644 --- a/src/infra/secrets-manager.ts +++ b/src/infra/secrets-manager.ts @@ -563,3 +563,170 @@ export function verifySecretHash(secret: string, hash: string): boolean { const computed = hashSecret(secret); return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(hash)); } + +// ═══════════════════════════════════════════════════════════════════════════════ +// FILE ENCRYPTION UTILITIES +// ═══════════════════════════════════════════════════════════════════════════════ + +/** + * Encrypts a file in place, creating a .enc version. + * The original file can be optionally removed for security. + * + * @returns Path to the encrypted file, or null on failure + */ +export function encryptFile( + filePath: string, + opts: { removeOriginal?: boolean; password?: string } = {}, +): string | null { + try { + if (!fs.existsSync(filePath)) { + log.warn("Cannot encrypt non-existent file", { filePath }); + return null; + } + + const content = fs.readFileSync(filePath, "utf8"); + const encrypted = encryptData(content, opts.password); + const encryptedPath = `${filePath}.enc`; + + fs.writeFileSync(encryptedPath, encrypted, { mode: 0o600 }); + + if (opts.removeOriginal) { + fs.unlinkSync(filePath); + } + + log.info("Encrypted file", { filePath, encryptedPath }); + return encryptedPath; + } catch (err) { + log.error("Failed to encrypt file", { filePath, error: String(err) }); + return null; + } +} + +/** + * Decrypts a .enc file back to its original form. + * The encrypted file can be optionally removed after decryption. + * + * @returns The decrypted content, or null on failure + */ +export function decryptFile( + encryptedPath: string, + opts: { removeEncrypted?: boolean; password?: string } = {}, +): string | null { + try { + if (!fs.existsSync(encryptedPath)) { + log.warn("Cannot decrypt non-existent file", { encryptedPath }); + return null; + } + + const encrypted = fs.readFileSync(encryptedPath); + const content = decryptData(encrypted, opts.password); + + if (opts.removeEncrypted) { + fs.unlinkSync(encryptedPath); + } + + return content; + } catch (err) { + log.error("Failed to decrypt file", { encryptedPath, error: String(err) }); + return null; + } +} + +/** + * Reads a file that may be encrypted or plaintext. + * If the .enc version exists, decrypts it. Otherwise reads the plaintext version. + * + * @returns Object with content and whether it was encrypted + */ +export function readPossiblyEncryptedFile( + basePath: string, + opts: { password?: string } = {}, +): { content: string | null; encrypted: boolean } { + const encryptedPath = `${basePath}.enc`; + + // Prefer encrypted version if it exists + if (fs.existsSync(encryptedPath)) { + const content = decryptFile(encryptedPath, opts); + return { content, encrypted: true }; + } + + // Fall back to plaintext + if (fs.existsSync(basePath)) { + try { + const content = fs.readFileSync(basePath, "utf8"); + return { content, encrypted: false }; + } catch (err) { + log.error("Failed to read plaintext file", { basePath, error: String(err) }); + return { content: null, encrypted: false }; + } + } + + return { content: null, encrypted: false }; +} + +/** + * Writes a file encrypted. If a plaintext version exists, can migrate it. + * + * @returns Path to the encrypted file, or null on failure + */ +export function writePossiblyEncryptedFile( + basePath: string, + content: string, + opts: { encrypt?: boolean; password?: string; removePlaintext?: boolean } = {}, +): string | null { + try { + if (opts.encrypt !== false) { + // Write encrypted version + const encryptedPath = `${basePath}.enc`; + const encrypted = encryptData(content, opts.password); + const dir = path.dirname(encryptedPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(encryptedPath, encrypted, { mode: 0o600 }); + + // Optionally remove plaintext version + if (opts.removePlaintext && fs.existsSync(basePath)) { + fs.unlinkSync(basePath); + } + + return encryptedPath; + } else { + // Write plaintext (for compatibility) + const dir = path.dirname(basePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(basePath, content, { mode: 0o600 }); + return basePath; + } + } catch (err) { + log.error("Failed to write file", { basePath, error: String(err) }); + return null; + } +} + +/** + * Migrates an existing plaintext file to encrypted storage. + * + * @returns Path to the encrypted file, or null if migration failed or wasn't needed + */ +export function migrateFileToEncrypted( + filePath: string, + opts: { removeOriginal?: boolean; password?: string } = {}, +): string | null { + const encryptedPath = `${filePath}.enc`; + + // Already encrypted + if (fs.existsSync(encryptedPath)) { + log.info("File already encrypted", { filePath }); + return encryptedPath; + } + + // No plaintext to migrate + if (!fs.existsSync(filePath)) { + return null; + } + + return encryptFile(filePath, opts); +} diff --git a/src/slack/accounts.ts b/src/slack/accounts.ts index 1ae42b5aa..4d8cb4a7a 100644 --- a/src/slack/accounts.ts +++ b/src/slack/accounts.ts @@ -1,10 +1,18 @@ import type { MoltbotConfig } from "../config/config.js"; import type { SlackAccountConfig } from "../config/types.js"; import { normalizeChatType } from "../channels/chat-type.js"; +import { getSecret } from "../infra/secrets-manager.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import { resolveSlackAppToken, resolveSlackBotToken } from "./token.js"; -export type SlackTokenSource = "env" | "config" | "none"; +export type SlackTokenSource = "env" | "config" | "secrets" | "none"; + +/** + * Resolves the secret key name for Slack tokens. + */ +function getSlackSecretKey(accountId: string, tokenType: "bot" | "app"): string { + return `slack-${tokenType}-token-${accountId}`; +} export type ResolvedSlackAccount = { accountId: string; @@ -63,9 +71,17 @@ function mergeSlackAccountConfig(cfg: MoltbotConfig, accountId: string): SlackAc return { ...base, ...account }; } +/** + * Resolves Slack tokens from multiple sources in priority order: + * 1. Secure secrets storage (keychain/encrypted file) + * 2. Config file (channels.slack.botToken / appToken) + * 3. Environment variables (SLACK_BOT_TOKEN / SLACK_APP_TOKEN) for default account only + */ export function resolveSlackAccount(params: { cfg: MoltbotConfig; accountId?: string | null; + /** Skip secrets manager lookup (for testing config/env precedence in isolation) */ + skipSecrets?: boolean; }): ResolvedSlackAccount { const accountId = normalizeAccountId(params.accountId); const baseEnabled = params.cfg.channels?.slack?.enabled !== false; @@ -73,14 +89,46 @@ export function resolveSlackAccount(params: { const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; const allowEnv = accountId === DEFAULT_ACCOUNT_ID; - const envBot = allowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined; - const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined; + + // Priority 1: Check secure secrets storage first (unless explicitly skipped) + let secretsBot: string | undefined; + let secretsApp: string | undefined; + if (!params.skipSecrets) { + const botSecretResult = getSecret(getSlackSecretKey(accountId, "bot")); + if (botSecretResult.success && botSecretResult.value) { + secretsBot = botSecretResult.value.trim() || undefined; + } + const appSecretResult = getSecret(getSlackSecretKey(accountId, "app")); + if (appSecretResult.success && appSecretResult.value) { + secretsApp = appSecretResult.value.trim() || undefined; + } + } + + // Priority 2: Config file const configBot = resolveSlackBotToken(merged.botToken); const configApp = resolveSlackAppToken(merged.appToken); - const botToken = configBot ?? envBot; - const appToken = configApp ?? envApp; - const botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none"; - const appTokenSource: SlackTokenSource = configApp ? "config" : envApp ? "env" : "none"; + + // Priority 3: Environment variables (default account only) + const envBot = allowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined; + const envApp = allowEnv ? resolveSlackAppToken(process.env.SLACK_APP_TOKEN) : undefined; + + // Resolve final tokens with priority order + const botToken = secretsBot ?? configBot ?? envBot; + const appToken = secretsApp ?? configApp ?? envApp; + const botTokenSource: SlackTokenSource = secretsBot + ? "secrets" + : configBot + ? "config" + : envBot + ? "env" + : "none"; + const appTokenSource: SlackTokenSource = secretsApp + ? "secrets" + : configApp + ? "config" + : envApp + ? "env" + : "none"; return { accountId, diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index 80eb535a3..d44569031 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -3,7 +3,7 @@ import type { TelegramAccountConfig } from "../config/types.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -import { resolveTelegramToken } from "./token.js"; +import { resolveTelegramToken, type TelegramTokenSource } from "./token.js"; const debugAccounts = (...args: unknown[]) => { if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_TELEGRAM_ACCOUNTS)) { @@ -16,7 +16,7 @@ export type ResolvedTelegramAccount = { enabled: boolean; name?: string; token: string; - tokenSource: "env" | "tokenFile" | "config" | "none"; + tokenSource: TelegramTokenSource; config: TelegramAccountConfig; }; diff --git a/src/telegram/token.test.ts b/src/telegram/token.test.ts index 08e17519a..5c62eb7a6 100644 --- a/src/telegram/token.test.ts +++ b/src/telegram/token.test.ts @@ -21,7 +21,8 @@ describe("resolveTelegramToken", () => { const cfg = { channels: { telegram: { botToken: "cfg-token" } }, } as MoltbotConfig; - const res = resolveTelegramToken(cfg); + // Skip secrets to test config/env precedence in isolation + const res = resolveTelegramToken(cfg, { skipSecrets: true }); expect(res.token).toBe("cfg-token"); expect(res.source).toBe("config"); }); @@ -31,7 +32,8 @@ describe("resolveTelegramToken", () => { const cfg = { channels: { telegram: {} }, } as MoltbotConfig; - const res = resolveTelegramToken(cfg); + // Skip secrets to test config/env precedence in isolation + const res = resolveTelegramToken(cfg, { skipSecrets: true }); expect(res.token).toBe("env-token"); expect(res.source).toBe("env"); }); @@ -42,7 +44,8 @@ describe("resolveTelegramToken", () => { const tokenFile = path.join(dir, "token.txt"); fs.writeFileSync(tokenFile, "file-token\n", "utf-8"); const cfg = { channels: { telegram: { tokenFile } } } as MoltbotConfig; - const res = resolveTelegramToken(cfg); + // Skip secrets to test tokenFile precedence in isolation + const res = resolveTelegramToken(cfg, { skipSecrets: true }); expect(res.token).toBe("file-token"); expect(res.source).toBe("tokenFile"); fs.rmSync(dir, { recursive: true, force: true }); @@ -53,7 +56,8 @@ describe("resolveTelegramToken", () => { const cfg = { channels: { telegram: { botToken: "cfg-token" } }, } as MoltbotConfig; - const res = resolveTelegramToken(cfg); + // Skip secrets to test config/env precedence in isolation + const res = resolveTelegramToken(cfg, { skipSecrets: true }); expect(res.token).toBe("cfg-token"); expect(res.source).toBe("config"); }); @@ -65,7 +69,8 @@ describe("resolveTelegramToken", () => { const cfg = { channels: { telegram: { tokenFile, botToken: "cfg-token" } }, } as MoltbotConfig; - const res = resolveTelegramToken(cfg); + // Skip secrets to test tokenFile error behavior in isolation + const res = resolveTelegramToken(cfg, { skipSecrets: true }); expect(res.token).toBe(""); expect(res.source).toBe("none"); fs.rmSync(dir, { recursive: true, force: true }); diff --git a/src/telegram/token.ts b/src/telegram/token.ts index cf6ba5f62..c24739f99 100644 --- a/src/telegram/token.ts +++ b/src/telegram/token.ts @@ -1,9 +1,10 @@ import fs from "node:fs"; import type { MoltbotConfig } from "../config/config.js"; +import { getSecret } from "../infra/secrets-manager.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; -export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none"; +export type TelegramTokenSource = "env" | "tokenFile" | "config" | "secrets" | "none"; export type TelegramTokenResolution = { token: string; @@ -14,13 +15,40 @@ type ResolveTelegramTokenOpts = { envToken?: string | null; accountId?: string | null; logMissingFile?: (message: string) => void; + /** Skip secrets manager lookup (for testing config/env precedence in isolation) */ + skipSecrets?: boolean; }; +/** + * Resolves the secret key name for a Telegram token. + */ +function getTelegramSecretKey(accountId: string): string { + return `telegram-token-${accountId}`; +} + +/** + * Resolves Telegram bot token from multiple sources in priority order: + * 1. Secure secrets storage (keychain/encrypted file) + * 2. Token file (channels.telegram.tokenFile or account-specific) + * 3. Config file (channels.telegram.botToken) + * 4. Environment variable (TELEGRAM_BOT_TOKEN) for default account only + */ export function resolveTelegramToken( cfg?: MoltbotConfig, opts: ResolveTelegramTokenOpts = {}, ): TelegramTokenResolution { const accountId = normalizeAccountId(opts.accountId); + + // Priority 1: Check secure secrets storage first (unless explicitly skipped) + if (!opts.skipSecrets) { + const secretKey = getTelegramSecretKey(accountId); + const secretResult = getSecret(secretKey); + if (secretResult.success && secretResult.value) { + const secretToken = secretResult.value.trim(); + if (secretToken) return { token: secretToken, source: "secrets" }; + } + } + const telegramCfg = cfg?.channels?.telegram; const accountCfg = accountId !== DEFAULT_ACCOUNT_ID diff --git a/src/web/auth-store.ts b/src/web/auth-store.ts index 3e22fd22b..3a3359793 100644 --- a/src/web/auth-store.ts +++ b/src/web/auth-store.ts @@ -4,6 +4,11 @@ import path from "node:path"; import { resolveOAuthDir } from "../config/paths.js"; import { info, success } from "../globals.js"; +import { + readPossiblyEncryptedFile, + writePossiblyEncryptedFile, + migrateFileToEncrypted, +} from "../infra/secrets-manager.js"; import { getChildLogger } from "../logging.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; @@ -21,11 +26,29 @@ export function resolveWebCredsPath(authDir: string): string { return path.join(authDir, "creds.json"); } +export function resolveWebCredsEncryptedPath(authDir: string): string { + return path.join(authDir, "creds.json.enc"); +} + export function resolveWebCredsBackupPath(authDir: string): string { return path.join(authDir, "creds.json.bak"); } +export function resolveWebCredsBackupEncryptedPath(authDir: string): string { + return path.join(authDir, "creds.json.bak.enc"); +} + export function hasWebCredsSync(authDir: string): boolean { + // Check encrypted version first (preferred) + const encryptedPath = resolveWebCredsEncryptedPath(authDir); + try { + const stats = fsSync.statSync(encryptedPath); + if (stats.isFile() && stats.size > 1) return true; + } catch { + // Continue to check plaintext + } + + // Fall back to plaintext version try { const stats = fsSync.statSync(resolveWebCredsPath(authDir)); return stats.isFile() && stats.size > 1; @@ -34,15 +57,19 @@ export function hasWebCredsSync(authDir: string): boolean { } } +/** + * Reads credentials from a file, supporting both encrypted and plaintext formats. + * Prefers the encrypted version (.enc) if it exists. + */ function readCredsJsonRaw(filePath: string): string | null { - try { - if (!fsSync.existsSync(filePath)) return null; - const stats = fsSync.statSync(filePath); - if (!stats.isFile() || stats.size <= 1) return null; - return fsSync.readFileSync(filePath, "utf-8"); - } catch { - return null; - } + // Use the secrets-manager utility to handle encrypted/plaintext transparently + const result = readPossiblyEncryptedFile(filePath); + if (result.content === null) return null; + + // Validate content has meaningful size + if (result.content.length <= 1) return null; + + return result.content; } export function maybeRestoreCredsFromBackup(authDir: string): void { @@ -73,11 +100,30 @@ export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir() const resolvedAuthDir = resolveUserPath(authDir); maybeRestoreCredsFromBackup(resolvedAuthDir); const credsPath = resolveWebCredsPath(resolvedAuthDir); + try { await fs.access(resolvedAuthDir); } catch { return false; } + + // Check encrypted version first (preferred) + const encryptedPath = resolveWebCredsEncryptedPath(resolvedAuthDir); + try { + const stats = await fs.stat(encryptedPath); + if (stats.isFile() && stats.size > 1) { + // Validate by attempting to decrypt and parse + const result = readPossiblyEncryptedFile(credsPath); + if (result.content) { + JSON.parse(result.content); + return true; + } + } + } catch { + // Continue to check plaintext + } + + // Fall back to plaintext version try { const stats = await fs.stat(credsPath); if (!stats.isFile() || stats.size <= 1) return false; @@ -93,8 +139,15 @@ async function clearLegacyBaileysAuthState(authDir: string) { const entries = await fs.readdir(authDir, { withFileTypes: true }); const shouldDelete = (name: string) => { if (name === "oauth.json") return false; - if (name === "creds.json" || name === "creds.json.bak") return true; - if (!name.endsWith(".json")) return false; + // Include encrypted versions for cleanup + if ( + name === "creds.json" || + name === "creds.json.bak" || + name === "creds.json.enc" || + name === "creds.json.bak.enc" + ) + return true; + if (!name.endsWith(".json") && !name.endsWith(".enc")) return false; return /^(app-state-sync|session|sender-key|pre-key)-/.test(name); }; await Promise.all( @@ -106,6 +159,73 @@ async function clearLegacyBaileysAuthState(authDir: string) { ); } +/** + * Migrates plaintext WhatsApp credentials to encrypted storage. + * Returns true if migration occurred, false if already encrypted or no creds exist. + */ +export function migrateWebCredsToEncrypted(authDir: string = resolveDefaultWebAuthDir()): { + migrated: boolean; + error?: string; +} { + const logger = getChildLogger({ module: "web-session" }); + const resolvedAuthDir = resolveUserPath(authDir); + const credsPath = resolveWebCredsPath(resolvedAuthDir); + const backupPath = resolveWebCredsBackupPath(resolvedAuthDir); + + // Check if already encrypted + if (fsSync.existsSync(resolveWebCredsEncryptedPath(resolvedAuthDir))) { + logger.info({ authDir: resolvedAuthDir }, "WhatsApp credentials already encrypted"); + return { migrated: false }; + } + + // Check if plaintext exists + if (!fsSync.existsSync(credsPath)) { + return { migrated: false }; + } + + try { + // Migrate main credentials + const encryptedPath = migrateFileToEncrypted(credsPath, { removeOriginal: true }); + if (!encryptedPath) { + return { migrated: false, error: "Failed to encrypt credentials file" }; + } + + // Also migrate backup if it exists + if (fsSync.existsSync(backupPath)) { + migrateFileToEncrypted(backupPath, { removeOriginal: true }); + } + + logger.info({ authDir: resolvedAuthDir }, "Migrated WhatsApp credentials to encrypted storage"); + return { migrated: true }; + } catch (err) { + logger.error( + { authDir: resolvedAuthDir, error: String(err) }, + "Failed to migrate WhatsApp credentials", + ); + return { migrated: false, error: String(err) }; + } +} + +/** + * Writes WhatsApp credentials to encrypted storage. + */ +export function writeWebCreds( + authDir: string, + creds: unknown, + opts: { encrypt?: boolean } = {}, +): boolean { + const resolvedAuthDir = resolveUserPath(authDir); + const credsPath = resolveWebCredsPath(resolvedAuthDir); + const content = JSON.stringify(creds, null, 2); + + const result = writePossiblyEncryptedFile(credsPath, content, { + encrypt: opts.encrypt !== false, + removePlaintext: true, + }); + + return result !== null; +} + export async function logoutWeb(params: { authDir?: string; isLegacyAuthDir?: boolean; @@ -121,21 +241,23 @@ export async function logoutWeb(params: { if (params.isLegacyAuthDir) { await clearLegacyBaileysAuthState(resolvedAuthDir); } else { + // Remove both encrypted and plaintext versions await fs.rm(resolvedAuthDir, { recursive: true, force: true }); } - runtime.log(success("Cleared WhatsApp Web credentials.")); + runtime.log(success("Cleared WhatsApp Web credentials (including encrypted).")); return true; } export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) { // Read the cached WhatsApp Web identity (jid + E.164) from disk if present. + // Supports both encrypted and plaintext credentials. try { const credsPath = resolveWebCredsPath(resolveUserPath(authDir)); - if (!fsSync.existsSync(credsPath)) { + const result = readPossiblyEncryptedFile(credsPath); + if (!result.content) { return { e164: null, jid: null } as const; } - const raw = fsSync.readFileSync(credsPath, "utf-8"); - const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined; + const parsed = JSON.parse(result.content) as { me?: { id?: string } } | undefined; const jid = parsed?.me?.id ?? null; const e164 = jid ? jidToE164(jid, { authDir }) : null; return { e164, jid } as const; @@ -147,10 +269,22 @@ export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) { /** * Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing. * Helpful for heartbeats/observability to spot stale credentials. + * Checks encrypted version first, then falls back to plaintext. */ export function getWebAuthAgeMs(authDir: string = resolveDefaultWebAuthDir()): number | null { + const resolvedAuthDir = resolveUserPath(authDir); + + // Check encrypted version first (preferred) try { - const stats = fsSync.statSync(resolveWebCredsPath(resolveUserPath(authDir))); + const stats = fsSync.statSync(resolveWebCredsEncryptedPath(resolvedAuthDir)); + return Date.now() - stats.mtimeMs; + } catch { + // Continue to check plaintext + } + + // Fall back to plaintext version + try { + const stats = fsSync.statSync(resolveWebCredsPath(resolvedAuthDir)); return Date.now() - stats.mtimeMs; } catch { return null;