security: integrate secrets-manager with all token stores

- Discord: secrets-manager as Priority 1 source, added "secrets" token source type
- Telegram: secrets-manager as Priority 1 source, added "secrets" token source type
- Slack: secrets-manager as Priority 1 source for both bot and app tokens
- WhatsApp: encrypted credentials support (.enc files) via readPossiblyEncryptedFile
- Added file encryption utilities: encryptFile, decryptFile, writePossiblyEncryptedFile
- Added migrateWebCredsToEncrypted() for WhatsApp credential migration
- All token tests updated with skipSecrets option for isolation testing

Priority order for all token resolution:
1. Secure secrets storage (keychain/encrypted file)
2. Config file (existing behavior)
3. Environment variables (default account only)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
ronitchidara 2026-01-27 13:00:15 +05:30
parent 86261f4860
commit 9d26cd5db2
10 changed files with 698 additions and 37 deletions

240
CLAWDBOT-SECURITY-PLAN.md Normal file
View File

@ -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: <description>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
```
### 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 <path>` 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*

View File

@ -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;
};

View File

@ -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");
});
});

View File

@ -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;

View File

@ -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);
}

View File

@ -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,

View File

@ -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;
};

View File

@ -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 });

View File

@ -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

View File

@ -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;