Skip to content

Commit 09980c4

Browse files
Antonio Noelclaude
authored andcommitted
security: add config HMAC integrity + scope enforcement on config.patch
- Add HMAC-SHA256 signature on config writes, verify on load/reload - Hard-reject externally-modified configs (not just warn) - Require operator.admin scope for config.patch - Add config audit log (config-audit.jsonl) - Add CodexBar onboarding option (skip LM config) - Add security documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dcd5473 commit 09980c4

11 files changed

Lines changed: 2048 additions & 1137 deletions

File tree

docs/security/config-protection.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Config Protection
2+
3+
This document describes the security hardening measures for OpenClaw gateway configuration.
4+
5+
## HMAC Integrity Check
6+
7+
The gateway computes an HMAC-SHA256 signature of the config file after every write. The signature is stored in a sidecar file (`<configPath>.sig`) alongside the config file.
8+
9+
### How it works
10+
11+
1. **On write**: After the gateway writes `openclaw.json`, it computes `HMAC-SHA256(content, gatewayToken)` and writes the hex digest to `openclaw.json.sig`.
12+
2. **On load**: When the gateway reads `openclaw.json`, it checks whether a `.sig` file exists. If it does, the gateway recomputes the HMAC and compares it to the stored value.
13+
3. **On mismatch**: If the HMAC does not match, the gateway logs a warning (`[security] config integrity warning: config file was modified outside the gateway process`) and sets `integrityWarning` on the config snapshot. The config is still loaded normally to avoid breaking manual editing workflows.
14+
15+
### Key material
16+
17+
The HMAC key is the gateway token stored at `~/.openclaw/gateway.token`. If no token file exists, HMAC signing and verification are skipped silently.
18+
19+
### Limitations
20+
21+
- Manual edits to the config file will trigger a mismatch warning. This is expected behavior.
22+
- The HMAC protects integrity, not confidentiality. The config file content is not encrypted.
23+
- If the gateway token is compromised, the HMAC can be forged.
24+
25+
## Scope Requirements for config.patch
26+
27+
The `config.patch` WebSocket method now requires the `operator.admin` scope. Clients without this scope receive an error:
28+
29+
```
30+
code: INVALID_REQUEST
31+
message: "config.patch requires operator.admin scope"
32+
```
33+
34+
The `config.set` and `config.apply` methods are also classified under the `operator.admin` scope in the method scope registry.
35+
36+
Previously, these config-mutating methods were not explicitly classified and fell through to the default admin requirement. They are now explicitly listed in the admin scope group for clarity and auditability.
37+
38+
## Config Security Audit Log
39+
40+
Every config write and detected external modification is logged to `~/.openclaw/logs/config-audit.jsonl`. Each entry contains:
41+
42+
| Field | Description |
43+
| -------------- | -------------------------------------------------------------------------- |
44+
| `timestamp` | ISO 8601 timestamp of the event |
45+
| `actor` | `"gateway"` for internal writes, `"filesystem"` for external modifications |
46+
| `changedPaths` | List of config paths that changed |
47+
| `sourceHash` | SHA-256 hash of the config before the change |
48+
| `resultHash` | SHA-256 hash of the config after the change |
49+
50+
This audit log is separate from the existing `config-audit.jsonl` written by `io.audit.ts`, which captures lower-level write mechanics (rename vs copy, inode metadata, etc.). The security audit log focuses on actor attribution and change tracking.
51+
52+
## Security Audit Integration
53+
54+
The `openclaw security audit` command checks for:
55+
56+
- **Config HMAC mismatch** (`config.hmac_integrity_mismatch`, severity: warn): Flags when the config file has been modified outside the gateway process.
57+
- **Insecure auth toggle** (`gateway.control_ui.insecure_auth`, severity: warn): Flags when `gateway.controlUi.allowInsecureAuth` is enabled.

src/commands/auth-choice-options.static.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI } from "./auth-choice-legacy.js";
1+
import { resolveLegacyAuthChoiceAliasesForCli } from "./auth-choice-legacy.js";
22
import type { AuthChoice, AuthChoiceGroupId } from "./onboard-types.js";
33

44
export type { AuthChoiceGroupId };
@@ -10,6 +10,8 @@ export type AuthChoiceOption = {
1010
groupId?: AuthChoiceGroupId;
1111
groupLabel?: string;
1212
groupHint?: string;
13+
assistantPriority?: number;
14+
assistantVisibility?: "visible" | "manual-only";
1315
};
1416

1517
export type AuthChoiceGroup = {
@@ -20,21 +22,6 @@ export type AuthChoiceGroup = {
2022
};
2123

2224
export const CORE_AUTH_CHOICE_OPTIONS: ReadonlyArray<AuthChoiceOption> = [
23-
{
24-
value: "chutes",
25-
label: "Chutes (OAuth)",
26-
groupId: "chutes",
27-
groupLabel: "Chutes",
28-
groupHint: "OAuth",
29-
},
30-
{
31-
value: "litellm-api-key",
32-
label: "LiteLLM API key",
33-
hint: "Unified gateway for 100+ LLM providers",
34-
groupId: "litellm",
35-
groupLabel: "LiteLLM",
36-
groupHint: "Unified LLM gateway (100+ providers)",
37-
},
3825
{
3926
value: "custom-api-key",
4027
label: "Custom Provider",
@@ -43,11 +30,22 @@ export const CORE_AUTH_CHOICE_OPTIONS: ReadonlyArray<AuthChoiceOption> = [
4330
groupLabel: "Custom Provider",
4431
groupHint: "Any OpenAI or Anthropic compatible endpoint",
4532
},
33+
{
34+
value: "codexbar",
35+
label: "CodexBar",
36+
hint: "Use CodexBar as your LM provider manager",
37+
groupId: "codexbar",
38+
groupLabel: "CodexBar (External LM Manager)",
39+
groupHint: "Skip LM config — manage providers via CodexBar after setup",
40+
},
4641
];
4742

4843
export function formatStaticAuthChoiceChoicesForCli(params?: {
4944
includeSkip?: boolean;
5045
includeLegacyAliases?: boolean;
46+
config?: import("../config/config.js").OpenClawConfig;
47+
workspaceDir?: string;
48+
env?: NodeJS.ProcessEnv;
5149
}): string {
5250
const includeSkip = params?.includeSkip ?? true;
5351
const includeLegacyAliases = params?.includeLegacyAliases ?? false;
@@ -57,7 +55,7 @@ export function formatStaticAuthChoiceChoicesForCli(params?: {
5755
values.push("skip");
5856
}
5957
if (includeLegacyAliases) {
60-
values.push(...AUTH_CHOICE_LEGACY_ALIASES_FOR_CLI);
58+
values.push(...resolveLegacyAuthChoiceAliasesForCli(params));
6159
}
6260

6361
return values.join("|");

src/config/config-audit.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
import { resolveStateDir } from "./paths.js";
4+
5+
const CONFIG_SECURITY_AUDIT_FILENAME = "config-audit.jsonl";
6+
7+
export type ConfigSecurityAuditActor = "gateway" | "filesystem";
8+
9+
export type ConfigSecurityAuditEntry = {
10+
timestamp: string;
11+
actor: ConfigSecurityAuditActor;
12+
changedPaths: string[];
13+
sourceHash: string | null;
14+
resultHash: string | null;
15+
};
16+
17+
function resolveConfigSecurityAuditLogPath(env: NodeJS.ProcessEnv, homedir: () => string): string {
18+
return path.join(resolveStateDir(env, homedir), "logs", CONFIG_SECURITY_AUDIT_FILENAME);
19+
}
20+
21+
/**
22+
* Appends a config security audit record to the audit log.
23+
* Best-effort; failures are silently ignored.
24+
*/
25+
export async function appendConfigSecurityAuditEntry(params: {
26+
env: NodeJS.ProcessEnv;
27+
homedir: () => string;
28+
actor: ConfigSecurityAuditActor;
29+
changedPaths: string[];
30+
sourceHash: string | null;
31+
resultHash: string | null;
32+
}): Promise<void> {
33+
try {
34+
const auditPath = resolveConfigSecurityAuditLogPath(params.env, params.homedir);
35+
const entry: ConfigSecurityAuditEntry = {
36+
timestamp: new Date().toISOString(),
37+
actor: params.actor,
38+
changedPaths: params.changedPaths,
39+
sourceHash: params.sourceHash,
40+
resultHash: params.resultHash,
41+
};
42+
await fs.promises.mkdir(path.dirname(auditPath), { recursive: true, mode: 0o700 });
43+
await fs.promises.appendFile(auditPath, `${JSON.stringify(entry)}\n`, {
44+
encoding: "utf-8",
45+
mode: 0o600,
46+
});
47+
} catch {
48+
// best-effort
49+
}
50+
}
51+
52+
/**
53+
* Synchronous variant for use in sync config load paths.
54+
* Best-effort; failures are silently ignored.
55+
*/
56+
export function appendConfigSecurityAuditEntrySync(params: {
57+
env: NodeJS.ProcessEnv;
58+
homedir: () => string;
59+
actor: ConfigSecurityAuditActor;
60+
changedPaths: string[];
61+
sourceHash: string | null;
62+
resultHash: string | null;
63+
}): void {
64+
try {
65+
const auditPath = resolveConfigSecurityAuditLogPath(params.env, params.homedir);
66+
const entry: ConfigSecurityAuditEntry = {
67+
timestamp: new Date().toISOString(),
68+
actor: params.actor,
69+
changedPaths: params.changedPaths,
70+
sourceHash: params.sourceHash,
71+
resultHash: params.resultHash,
72+
};
73+
fs.mkdirSync(path.dirname(auditPath), { recursive: true, mode: 0o700 });
74+
fs.appendFileSync(auditPath, `${JSON.stringify(entry)}\n`, {
75+
encoding: "utf-8",
76+
mode: 0o600,
77+
});
78+
} catch {
79+
// best-effort
80+
}
81+
}

src/config/io.hmac-integrity.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import crypto from "node:crypto";
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
6+
const GATEWAY_TOKEN_FILENAME = "gateway.token";
7+
const SIG_EXTENSION = ".sig";
8+
9+
let cachedGatewayToken: string | null | undefined;
10+
11+
/**
12+
* Resolves the gateway token path at ~/.openclaw/gateway.token.
13+
*/
14+
function resolveGatewayTokenPath(): string {
15+
return path.join(os.homedir(), ".openclaw", GATEWAY_TOKEN_FILENAME);
16+
}
17+
18+
/**
19+
* Reads and caches the gateway token from ~/.openclaw/gateway.token.
20+
* Returns null if the file does not exist or cannot be read.
21+
*/
22+
export function readGatewayToken(): string | null {
23+
if (cachedGatewayToken !== undefined) {
24+
return cachedGatewayToken;
25+
}
26+
try {
27+
const tokenPath = resolveGatewayTokenPath();
28+
const raw = fs.readFileSync(tokenPath, "utf-8").trim();
29+
cachedGatewayToken = raw.length > 0 ? raw : null;
30+
} catch {
31+
cachedGatewayToken = null;
32+
}
33+
return cachedGatewayToken;
34+
}
35+
36+
/**
37+
* Computes HMAC-SHA256 of config content using the gateway token.
38+
*/
39+
export function computeConfigHmac(content: string, token: string): string {
40+
return crypto.createHmac("sha256", token).update(content).digest("hex");
41+
}
42+
43+
/**
44+
* Resolves the sidecar HMAC signature file path for a config path.
45+
*/
46+
export function resolveConfigSigPath(configPath: string): string {
47+
return `${configPath}${SIG_EXTENSION}`;
48+
}
49+
50+
/**
51+
* Writes the HMAC signature sidecar file after a config write.
52+
* Best-effort; failures are silently ignored.
53+
*/
54+
export async function writeConfigHmacSig(configPath: string, configContent: string): Promise<void> {
55+
const token = readGatewayToken();
56+
if (!token) {
57+
return;
58+
}
59+
try {
60+
const hmac = computeConfigHmac(configContent, token);
61+
const sigPath = resolveConfigSigPath(configPath);
62+
await fs.promises.writeFile(sigPath, hmac, { encoding: "utf-8", mode: 0o600 });
63+
} catch {
64+
// best-effort
65+
}
66+
}
67+
68+
export type ConfigHmacVerifyResult =
69+
| { status: "ok" }
70+
| { status: "no-token" }
71+
| { status: "no-sig" }
72+
| { status: "mismatch" }
73+
| { status: "error"; detail: string };
74+
75+
/**
76+
* Verifies the HMAC signature sidecar file against config content.
77+
* Returns a discriminated result indicating the verification outcome.
78+
*/
79+
export function verifyConfigHmac(
80+
configPath: string,
81+
configContent: string,
82+
): ConfigHmacVerifyResult {
83+
const token = readGatewayToken();
84+
if (!token) {
85+
return { status: "no-token" };
86+
}
87+
const sigPath = resolveConfigSigPath(configPath);
88+
let storedHmac: string;
89+
try {
90+
storedHmac = fs.readFileSync(sigPath, "utf-8").trim();
91+
} catch {
92+
return { status: "no-sig" };
93+
}
94+
if (storedHmac.length === 0) {
95+
return { status: "no-sig" };
96+
}
97+
try {
98+
const expectedHmac = computeConfigHmac(configContent, token);
99+
if (storedHmac === expectedHmac) {
100+
return { status: "ok" };
101+
}
102+
return { status: "mismatch" };
103+
} catch (err: unknown) {
104+
const detail = err instanceof Error ? err.message : "unknown error during HMAC verification";
105+
return { status: "error", detail };
106+
}
107+
}
108+
109+
/**
110+
* Clears the cached gateway token. Useful for testing.
111+
*/
112+
export function clearGatewayTokenCache(): void {
113+
cachedGatewayToken = undefined;
114+
}

0 commit comments

Comments
 (0)