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:
parent
86261f4860
commit
9d26cd5db2
240
CLAWDBOT-SECURITY-PLAN.md
Normal file
240
CLAWDBOT-SECURITY-PLAN.md
Normal 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*
|
||||||
@ -1,14 +1,14 @@
|
|||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { DiscordAccountConfig } from "../config/types.js";
|
import type { DiscordAccountConfig } from "../config/types.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.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 = {
|
export type ResolvedDiscordAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
token: string;
|
token: string;
|
||||||
tokenSource: "env" | "config" | "none";
|
tokenSource: DiscordTokenSource;
|
||||||
config: DiscordAccountConfig;
|
config: DiscordAccountConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -13,7 +13,8 @@ describe("resolveDiscordToken", () => {
|
|||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { discord: { token: "cfg-token" } },
|
channels: { discord: { token: "cfg-token" } },
|
||||||
} as MoltbotConfig;
|
} 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.token).toBe("cfg-token");
|
||||||
expect(res.source).toBe("config");
|
expect(res.source).toBe("config");
|
||||||
});
|
});
|
||||||
@ -23,7 +24,8 @@ describe("resolveDiscordToken", () => {
|
|||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { discord: {} },
|
channels: { discord: {} },
|
||||||
} as MoltbotConfig;
|
} 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.token).toBe("env-token");
|
||||||
expect(res.source).toBe("env");
|
expect(res.source).toBe("env");
|
||||||
});
|
});
|
||||||
@ -40,8 +42,18 @@ describe("resolveDiscordToken", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as MoltbotConfig;
|
} 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.token).toBe("acct-token");
|
||||||
expect(res.source).toBe("config");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
|
import { getSecret } from "../infra/secrets-manager.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.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 = {
|
export type DiscordTokenResolution = {
|
||||||
token: string;
|
token: string;
|
||||||
@ -15,11 +16,36 @@ export function normalizeDiscordToken(raw?: string | null): string | undefined {
|
|||||||
return trimmed.replace(/^Bot\s+/i, "");
|
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(
|
export function resolveDiscordToken(
|
||||||
cfg?: MoltbotConfig,
|
cfg?: MoltbotConfig,
|
||||||
opts: { accountId?: string | null; envToken?: string | null } = {},
|
opts: { accountId?: string | null; envToken?: string | null; skipSecrets?: boolean } = {},
|
||||||
): DiscordTokenResolution {
|
): DiscordTokenResolution {
|
||||||
const accountId = normalizeAccountId(opts.accountId);
|
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 discordCfg = cfg?.channels?.discord;
|
||||||
const accountCfg =
|
const accountCfg =
|
||||||
accountId !== DEFAULT_ACCOUNT_ID
|
accountId !== DEFAULT_ACCOUNT_ID
|
||||||
@ -32,6 +58,7 @@ export function resolveDiscordToken(
|
|||||||
const configToken = allowEnv ? normalizeDiscordToken(discordCfg?.token ?? undefined) : undefined;
|
const configToken = allowEnv ? normalizeDiscordToken(discordCfg?.token ?? undefined) : undefined;
|
||||||
if (configToken) return { token: configToken, source: "config" };
|
if (configToken) return { token: configToken, source: "config" };
|
||||||
|
|
||||||
|
// Priority 3: Check environment variable (default account only)
|
||||||
const envToken = allowEnv
|
const envToken = allowEnv
|
||||||
? normalizeDiscordToken(opts.envToken ?? process.env.DISCORD_BOT_TOKEN)
|
? normalizeDiscordToken(opts.envToken ?? process.env.DISCORD_BOT_TOKEN)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@ -563,3 +563,170 @@ export function verifySecretHash(secret: string, hash: string): boolean {
|
|||||||
const computed = hashSecret(secret);
|
const computed = hashSecret(secret);
|
||||||
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(hash));
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { SlackAccountConfig } from "../config/types.js";
|
import type { SlackAccountConfig } from "../config/types.js";
|
||||||
import { normalizeChatType } from "../channels/chat-type.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 { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
|
||||||
import { resolveSlackAppToken, resolveSlackBotToken } from "./token.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 = {
|
export type ResolvedSlackAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
@ -63,9 +71,17 @@ function mergeSlackAccountConfig(cfg: MoltbotConfig, accountId: string): SlackAc
|
|||||||
return { ...base, ...account };
|
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: {
|
export function resolveSlackAccount(params: {
|
||||||
cfg: MoltbotConfig;
|
cfg: MoltbotConfig;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
|
/** Skip secrets manager lookup (for testing config/env precedence in isolation) */
|
||||||
|
skipSecrets?: boolean;
|
||||||
}): ResolvedSlackAccount {
|
}): ResolvedSlackAccount {
|
||||||
const accountId = normalizeAccountId(params.accountId);
|
const accountId = normalizeAccountId(params.accountId);
|
||||||
const baseEnabled = params.cfg.channels?.slack?.enabled !== false;
|
const baseEnabled = params.cfg.channels?.slack?.enabled !== false;
|
||||||
@ -73,14 +89,46 @@ export function resolveSlackAccount(params: {
|
|||||||
const accountEnabled = merged.enabled !== false;
|
const accountEnabled = merged.enabled !== false;
|
||||||
const enabled = baseEnabled && accountEnabled;
|
const enabled = baseEnabled && accountEnabled;
|
||||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
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 configBot = resolveSlackBotToken(merged.botToken);
|
||||||
const configApp = resolveSlackAppToken(merged.appToken);
|
const configApp = resolveSlackAppToken(merged.appToken);
|
||||||
const botToken = configBot ?? envBot;
|
|
||||||
const appToken = configApp ?? envApp;
|
// Priority 3: Environment variables (default account only)
|
||||||
const botTokenSource: SlackTokenSource = configBot ? "config" : envBot ? "env" : "none";
|
const envBot = allowEnv ? resolveSlackBotToken(process.env.SLACK_BOT_TOKEN) : undefined;
|
||||||
const appTokenSource: SlackTokenSource = configApp ? "config" : envApp ? "env" : "none";
|
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 {
|
return {
|
||||||
accountId,
|
accountId,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import type { TelegramAccountConfig } from "../config/types.js";
|
|||||||
import { isTruthyEnvValue } from "../infra/env.js";
|
import { isTruthyEnvValue } from "../infra/env.js";
|
||||||
import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js";
|
import { listBoundAccountIds, resolveDefaultAgentBoundAccountId } from "../routing/bindings.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.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[]) => {
|
const debugAccounts = (...args: unknown[]) => {
|
||||||
if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_TELEGRAM_ACCOUNTS)) {
|
if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_TELEGRAM_ACCOUNTS)) {
|
||||||
@ -16,7 +16,7 @@ export type ResolvedTelegramAccount = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
name?: string;
|
name?: string;
|
||||||
token: string;
|
token: string;
|
||||||
tokenSource: "env" | "tokenFile" | "config" | "none";
|
tokenSource: TelegramTokenSource;
|
||||||
config: TelegramAccountConfig;
|
config: TelegramAccountConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -21,7 +21,8 @@ describe("resolveTelegramToken", () => {
|
|||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { telegram: { botToken: "cfg-token" } },
|
channels: { telegram: { botToken: "cfg-token" } },
|
||||||
} as MoltbotConfig;
|
} 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.token).toBe("cfg-token");
|
||||||
expect(res.source).toBe("config");
|
expect(res.source).toBe("config");
|
||||||
});
|
});
|
||||||
@ -31,7 +32,8 @@ describe("resolveTelegramToken", () => {
|
|||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { telegram: {} },
|
channels: { telegram: {} },
|
||||||
} as MoltbotConfig;
|
} 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.token).toBe("env-token");
|
||||||
expect(res.source).toBe("env");
|
expect(res.source).toBe("env");
|
||||||
});
|
});
|
||||||
@ -42,7 +44,8 @@ describe("resolveTelegramToken", () => {
|
|||||||
const tokenFile = path.join(dir, "token.txt");
|
const tokenFile = path.join(dir, "token.txt");
|
||||||
fs.writeFileSync(tokenFile, "file-token\n", "utf-8");
|
fs.writeFileSync(tokenFile, "file-token\n", "utf-8");
|
||||||
const cfg = { channels: { telegram: { tokenFile } } } as MoltbotConfig;
|
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.token).toBe("file-token");
|
||||||
expect(res.source).toBe("tokenFile");
|
expect(res.source).toBe("tokenFile");
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
@ -53,7 +56,8 @@ describe("resolveTelegramToken", () => {
|
|||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { telegram: { botToken: "cfg-token" } },
|
channels: { telegram: { botToken: "cfg-token" } },
|
||||||
} as MoltbotConfig;
|
} 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.token).toBe("cfg-token");
|
||||||
expect(res.source).toBe("config");
|
expect(res.source).toBe("config");
|
||||||
});
|
});
|
||||||
@ -65,7 +69,8 @@ describe("resolveTelegramToken", () => {
|
|||||||
const cfg = {
|
const cfg = {
|
||||||
channels: { telegram: { tokenFile, botToken: "cfg-token" } },
|
channels: { telegram: { tokenFile, botToken: "cfg-token" } },
|
||||||
} as MoltbotConfig;
|
} 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.token).toBe("");
|
||||||
expect(res.source).toBe("none");
|
expect(res.source).toBe("none");
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
|
||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
|
import { getSecret } from "../infra/secrets-manager.js";
|
||||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.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 = {
|
export type TelegramTokenResolution = {
|
||||||
token: string;
|
token: string;
|
||||||
@ -14,13 +15,40 @@ type ResolveTelegramTokenOpts = {
|
|||||||
envToken?: string | null;
|
envToken?: string | null;
|
||||||
accountId?: string | null;
|
accountId?: string | null;
|
||||||
logMissingFile?: (message: string) => void;
|
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(
|
export function resolveTelegramToken(
|
||||||
cfg?: MoltbotConfig,
|
cfg?: MoltbotConfig,
|
||||||
opts: ResolveTelegramTokenOpts = {},
|
opts: ResolveTelegramTokenOpts = {},
|
||||||
): TelegramTokenResolution {
|
): TelegramTokenResolution {
|
||||||
const accountId = normalizeAccountId(opts.accountId);
|
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 telegramCfg = cfg?.channels?.telegram;
|
||||||
const accountCfg =
|
const accountCfg =
|
||||||
accountId !== DEFAULT_ACCOUNT_ID
|
accountId !== DEFAULT_ACCOUNT_ID
|
||||||
|
|||||||
@ -4,6 +4,11 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { resolveOAuthDir } from "../config/paths.js";
|
import { resolveOAuthDir } from "../config/paths.js";
|
||||||
import { info, success } from "../globals.js";
|
import { info, success } from "../globals.js";
|
||||||
|
import {
|
||||||
|
readPossiblyEncryptedFile,
|
||||||
|
writePossiblyEncryptedFile,
|
||||||
|
migrateFileToEncrypted,
|
||||||
|
} from "../infra/secrets-manager.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
@ -21,11 +26,29 @@ export function resolveWebCredsPath(authDir: string): string {
|
|||||||
return path.join(authDir, "creds.json");
|
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 {
|
export function resolveWebCredsBackupPath(authDir: string): string {
|
||||||
return path.join(authDir, "creds.json.bak");
|
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 {
|
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 {
|
try {
|
||||||
const stats = fsSync.statSync(resolveWebCredsPath(authDir));
|
const stats = fsSync.statSync(resolveWebCredsPath(authDir));
|
||||||
return stats.isFile() && stats.size > 1;
|
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 {
|
function readCredsJsonRaw(filePath: string): string | null {
|
||||||
try {
|
// Use the secrets-manager utility to handle encrypted/plaintext transparently
|
||||||
if (!fsSync.existsSync(filePath)) return null;
|
const result = readPossiblyEncryptedFile(filePath);
|
||||||
const stats = fsSync.statSync(filePath);
|
if (result.content === null) return null;
|
||||||
if (!stats.isFile() || stats.size <= 1) return null;
|
|
||||||
return fsSync.readFileSync(filePath, "utf-8");
|
// Validate content has meaningful size
|
||||||
} catch {
|
if (result.content.length <= 1) return null;
|
||||||
return null;
|
|
||||||
}
|
return result.content;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function maybeRestoreCredsFromBackup(authDir: string): void {
|
export function maybeRestoreCredsFromBackup(authDir: string): void {
|
||||||
@ -73,11 +100,30 @@ export async function webAuthExists(authDir: string = resolveDefaultWebAuthDir()
|
|||||||
const resolvedAuthDir = resolveUserPath(authDir);
|
const resolvedAuthDir = resolveUserPath(authDir);
|
||||||
maybeRestoreCredsFromBackup(resolvedAuthDir);
|
maybeRestoreCredsFromBackup(resolvedAuthDir);
|
||||||
const credsPath = resolveWebCredsPath(resolvedAuthDir);
|
const credsPath = resolveWebCredsPath(resolvedAuthDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(resolvedAuthDir);
|
await fs.access(resolvedAuthDir);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
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 {
|
try {
|
||||||
const stats = await fs.stat(credsPath);
|
const stats = await fs.stat(credsPath);
|
||||||
if (!stats.isFile() || stats.size <= 1) return false;
|
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 entries = await fs.readdir(authDir, { withFileTypes: true });
|
||||||
const shouldDelete = (name: string) => {
|
const shouldDelete = (name: string) => {
|
||||||
if (name === "oauth.json") return false;
|
if (name === "oauth.json") return false;
|
||||||
if (name === "creds.json" || name === "creds.json.bak") return true;
|
// Include encrypted versions for cleanup
|
||||||
if (!name.endsWith(".json")) return false;
|
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);
|
return /^(app-state-sync|session|sender-key|pre-key)-/.test(name);
|
||||||
};
|
};
|
||||||
await Promise.all(
|
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: {
|
export async function logoutWeb(params: {
|
||||||
authDir?: string;
|
authDir?: string;
|
||||||
isLegacyAuthDir?: boolean;
|
isLegacyAuthDir?: boolean;
|
||||||
@ -121,21 +241,23 @@ export async function logoutWeb(params: {
|
|||||||
if (params.isLegacyAuthDir) {
|
if (params.isLegacyAuthDir) {
|
||||||
await clearLegacyBaileysAuthState(resolvedAuthDir);
|
await clearLegacyBaileysAuthState(resolvedAuthDir);
|
||||||
} else {
|
} else {
|
||||||
|
// Remove both encrypted and plaintext versions
|
||||||
await fs.rm(resolvedAuthDir, { recursive: true, force: true });
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
|
export function readWebSelfId(authDir: string = resolveDefaultWebAuthDir()) {
|
||||||
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
|
// Read the cached WhatsApp Web identity (jid + E.164) from disk if present.
|
||||||
|
// Supports both encrypted and plaintext credentials.
|
||||||
try {
|
try {
|
||||||
const credsPath = resolveWebCredsPath(resolveUserPath(authDir));
|
const credsPath = resolveWebCredsPath(resolveUserPath(authDir));
|
||||||
if (!fsSync.existsSync(credsPath)) {
|
const result = readPossiblyEncryptedFile(credsPath);
|
||||||
|
if (!result.content) {
|
||||||
return { e164: null, jid: null } as const;
|
return { e164: null, jid: null } as const;
|
||||||
}
|
}
|
||||||
const raw = fsSync.readFileSync(credsPath, "utf-8");
|
const parsed = JSON.parse(result.content) as { me?: { id?: string } } | undefined;
|
||||||
const parsed = JSON.parse(raw) as { me?: { id?: string } } | undefined;
|
|
||||||
const jid = parsed?.me?.id ?? null;
|
const jid = parsed?.me?.id ?? null;
|
||||||
const e164 = jid ? jidToE164(jid, { authDir }) : null;
|
const e164 = jid ? jidToE164(jid, { authDir }) : null;
|
||||||
return { e164, jid } as const;
|
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.
|
* Return the age (in milliseconds) of the cached WhatsApp web auth state, or null when missing.
|
||||||
* Helpful for heartbeats/observability to spot stale credentials.
|
* 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 {
|
export function getWebAuthAgeMs(authDir: string = resolveDefaultWebAuthDir()): number | null {
|
||||||
|
const resolvedAuthDir = resolveUserPath(authDir);
|
||||||
|
|
||||||
|
// Check encrypted version first (preferred)
|
||||||
try {
|
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;
|
return Date.now() - stats.mtimeMs;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user