diff --git a/CHANGELOG.md b/CHANGELOG.md index 87e1ea09392..ee19a1b9ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -237,6 +237,7 @@ Docs: https://docs.openclaw.ai - Message tool/buttons: keep the shared `buttons` schema optional in merged tool definitions so plain `action=send` calls stop failing validation when no buttons are provided. (#54418) Thanks @adzendo. - Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026. - Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava. +- Nostr/config: redact `channels.nostr.privateKey` in config snapshots and Control UI config views, so Nostr signing keys no longer appear in plain text. Thanks @ccreater222. - Subagents/announcements: preserve the requester agent id for inline deterministic tool spawns so named agents without channel bindings can still announce completions through the correct owner session. (#55437) Thanks @kAIborg24. - Tlon/media: route inbound image downloads through the shared media store, cap each download at 6 MB, and stop after 8 images per message so large Tlon posts no longer balloon local media storage. Thanks @AntAISecurityLab and @vincentkoc. - Telegram/Anthropic streaming: replace raw invalid stream-order provider errors with a safe retry message so internal `message_start/message_stop` failures do not leak into chats. (#55408) Thanks @imydal. diff --git a/extensions/nostr/src/channel.test.ts b/extensions/nostr/src/channel.test.ts index 7204b5c4c96..c066616dcc2 100644 --- a/extensions/nostr/src/channel.test.ts +++ b/extensions/nostr/src/channel.test.ts @@ -7,6 +7,7 @@ import { } from "../../../test/helpers/plugins/setup-wizard.js"; import type { OpenClawConfig } from "../runtime-api.js"; import { nostrPlugin } from "./channel.js"; +import { nostrSetupWizard } from "./setup-surface.js"; import { TEST_HEX_PRIVATE_KEY, TEST_SETUP_RELAY_URLS, @@ -225,6 +226,21 @@ describe("nostr account helpers", () => { const cfg = createConfiguredNostrCfg({ defaultAccount: "work" }); expect(listNostrAccountIds(cfg)).toEqual(["work"]); }); + + it("does not treat unresolved SecretRef privateKey as configured", () => { + const cfg = { + channels: { + nostr: { + privateKey: { + source: "env", + provider: "default", + id: "NOSTR_PRIVATE_KEY", + }, + }, + }, + }; + expect(listNostrAccountIds(cfg)).toEqual([]); + }); }); describe("resolveDefaultNostrAccountId", () => { @@ -313,6 +329,27 @@ describe("nostr account helpers", () => { expect(account.publicKey).toBe(""); }); + it("does not treat unresolved SecretRef privateKey as configured", () => { + const secretRef = { + source: "env" as const, + provider: "default", + id: "NOSTR_PRIVATE_KEY", + }; + const cfg = { + channels: { + nostr: { + privateKey: secretRef, + }, + }, + }; + const account = resolveNostrAccount({ cfg }); + + expect(account.configured).toBe(false); + expect(account.privateKey).toBe(""); + expect(account.publicKey).toBe(""); + expect(account.config.privateKey).toEqual(secretRef); + }); + it("preserves all config options", () => { const cfg = createConfiguredNostrCfg({ name: "Bot", @@ -333,4 +370,32 @@ describe("nostr account helpers", () => { }); }); }); + + describe("setup wizard", () => { + it("keeps unresolved SecretRef privateKey visible without marking the account configured", () => { + const secretRef = { + source: "env" as const, + provider: "default", + id: "NOSTR_PRIVATE_KEY", + }; + const cfg = { + channels: { + nostr: { + privateKey: secretRef, + }, + }, + }; + const credential = nostrSetupWizard.credentials?.[0]; + if (!credential?.inspect) { + throw new Error("nostr setup credential inspect missing"); + } + + expect(credential.inspect({ cfg, accountId: "default" })).toEqual({ + accountConfigured: false, + hasConfiguredValue: true, + resolvedValue: undefined, + envValue: undefined, + }); + }); + }); }); diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index dc6b6d8133a..13329d6506a 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -4,6 +4,7 @@ import { DmPolicySchema, MarkdownConfigSchema, } from "openclaw/plugin-sdk/channel-config-primitives"; +import { buildSecretInputSchema } from "openclaw/plugin-sdk/secret-input"; import { z } from "openclaw/plugin-sdk/zod"; /** @@ -73,7 +74,7 @@ export const NostrConfigSchema = z.object({ markdown: MarkdownConfigSchema, /** Private key in hex or nsec bech32 format */ - privateKey: z.string().optional(), + privateKey: buildSecretInputSchema().optional(), /** WebSocket relay URLs to connect to */ relays: z.array(z.string()).optional(), diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index 0feb6a1853c..c99b96a0e20 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -1,6 +1,10 @@ import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/secret-input"; import { createTopLevelChannelParsedAllowFromPrompt, createTopLevelChannelDmPolicy, @@ -165,7 +169,7 @@ export const nostrSetupWizard: ChannelSetupWizard = { isAvailable: ({ cfg, accountId }) => accountId === DEFAULT_ACCOUNT_ID && Boolean(process.env.NOSTR_PRIVATE_KEY?.trim()) && - !resolveNostrAccount({ cfg, accountId }).config.privateKey?.trim(), + !hasConfiguredSecretInput(resolveNostrAccount({ cfg, accountId }).config.privateKey), apply: async ({ cfg }) => patchTopLevelChannelConfigSection({ cfg, @@ -191,8 +195,8 @@ export const nostrSetupWizard: ChannelSetupWizard = { const account = resolveNostrAccount({ cfg, accountId }); return { accountConfigured: account.configured, - hasConfiguredValue: Boolean(account.config.privateKey?.trim()), - resolvedValue: account.config.privateKey?.trim(), + hasConfiguredValue: hasConfiguredSecretInput(account.config.privateKey), + resolvedValue: normalizeSecretInputString(account.config.privateKey), envValue: process.env.NOSTR_PRIVATE_KEY?.trim(), }; }, diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts index 93a5b78c89b..f8dcf2e3910 100644 --- a/extensions/nostr/src/types.ts +++ b/extensions/nostr/src/types.ts @@ -7,6 +7,7 @@ import { listCombinedAccountIds, resolveListedDefaultAccountId, } from "openclaw/plugin-sdk/account-resolution"; +import { normalizeSecretInputString, type SecretInput } from "openclaw/plugin-sdk/secret-input"; import type { OpenClawConfig } from "../api.js"; import type { NostrProfile } from "./config-schema.js"; import { DEFAULT_RELAYS } from "./default-relays.js"; @@ -16,7 +17,7 @@ export interface NostrAccountConfig { enabled?: boolean; name?: string; defaultAccount?: string; - privateKey?: string; + privateKey?: SecretInput; relays?: string[]; dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; allowFrom?: Array; @@ -49,9 +50,10 @@ export function listNostrAccountIds(cfg: OpenClawConfig): string[] { const nostrCfg = (cfg.channels as Record | undefined)?.nostr as | NostrAccountConfig | undefined; + const privateKey = normalizeSecretInputString(nostrCfg?.privateKey); return listCombinedAccountIds({ configuredAccountIds: [], - implicitAccountId: nostrCfg?.privateKey + implicitAccountId: privateKey ? (resolveConfiguredDefaultNostrAccountId(cfg) ?? DEFAULT_ACCOUNT_ID) : undefined, }); @@ -80,11 +82,11 @@ export function resolveNostrAccount(opts: { | undefined; const baseEnabled = nostrCfg?.enabled !== false; - const privateKey = nostrCfg?.privateKey ?? ""; - const configured = Boolean(privateKey.trim()); + const privateKey = normalizeSecretInputString(nostrCfg?.privateKey) ?? ""; + const configured = Boolean(privateKey); let publicKey = ""; - if (configured) { + if (privateKey) { try { publicKey = getPublicKeyFromPrivate(privateKey); } catch { diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 4a46adc488d..29fd0708417 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -8762,7 +8762,70 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ additionalProperties: false, }, privateKey: { - type: "string", + anyOf: [ + { + type: "string", + }, + { + oneOf: [ + { + type: "object", + properties: { + source: { + type: "string", + const: "env", + }, + provider: { + type: "string", + pattern: "^[a-z][a-z0-9_-]{0,63}$", + }, + id: { + type: "string", + pattern: "^[A-Z][A-Z0-9_]{0,127}$", + }, + }, + required: ["source", "provider", "id"], + additionalProperties: false, + }, + { + type: "object", + properties: { + source: { + type: "string", + const: "file", + }, + provider: { + type: "string", + pattern: "^[a-z][a-z0-9_-]{0,63}$", + }, + id: { + type: "string", + }, + }, + required: ["source", "provider", "id"], + additionalProperties: false, + }, + { + type: "object", + properties: { + source: { + type: "string", + const: "exec", + }, + provider: { + type: "string", + pattern: "^[a-z][a-z0-9_-]{0,63}$", + }, + id: { + type: "string", + }, + }, + required: ["source", "provider", "id"], + additionalProperties: false, + }, + ], + }, + ], }, relays: { type: "array", @@ -8826,6 +8889,11 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [ }, additionalProperties: false, }, + uiHints: { + privateKey: { + sensitive: true, + }, + }, }, { pluginId: "qqbot", diff --git a/src/config/redact-snapshot.schema.test.ts b/src/config/redact-snapshot.schema.test.ts index 4f7ebf23bd6..5f4335abc11 100644 --- a/src/config/redact-snapshot.schema.test.ts +++ b/src/config/redact-snapshot.schema.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { REDACTED_SENTINEL, redactConfigSnapshot } from "./redact-snapshot.js"; import { makeSnapshot, restoreRedactedValues } from "./redact-snapshot.test-helpers.js"; import { redactSnapshotTestHints as mainSchemaHints } from "./redact-snapshot.test-hints.js"; +import { buildConfigSchema } from "./schema.js"; describe("realredactConfigSnapshot_real", () => { it("main schema redact works (samples)", () => { @@ -34,4 +35,25 @@ describe("realredactConfigSnapshot_real", () => { expect(restored.agents.defaults.memorySearch.remote.apiKey).toBe("1234"); expect(restored.agents.list[0].memorySearch.remote.apiKey).toBe("6789"); }); + + it("redacts bundled channel private keys from generated schema hints", () => { + const hints = buildConfigSchema().uiHints; + const snapshot = makeSnapshot({ + channels: { + nostr: { + privateKey: "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + relays: ["wss://relay.example.com"], + }, + }, + }); + + const result = redactConfigSnapshot(snapshot, hints); + const channels = result.config.channels as Record>; + expect(channels.nostr.privateKey).toBe(REDACTED_SENTINEL); + + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.channels.nostr.privateKey).toBe( + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + ); + }); }); diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index e50c148d5b6..a6baa68f0f5 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -841,6 +841,30 @@ describe("redactConfigSnapshot", () => { expectGatewayAuthFieldValue(result, "password", REDACTED_SENTINEL); }); + it("redacts privateKey paths even when absent from uiHints (defense in depth)", () => { + const hints: ConfigUiHints = { + "some.other.path": { sensitive: true }, + }; + const snapshot = makeSnapshot({ + channels: { + nostr: { + privateKey: "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + relays: ["wss://relay.example.com"], + }, + }, + }); + + const result = redactConfigSnapshot(snapshot, hints); + const channels = result.config.channels as Record>; + expect(channels.nostr.privateKey).toBe(REDACTED_SENTINEL); + expect(channels.nostr.relays).toEqual(["wss://relay.example.com"]); + + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.channels.nostr.privateKey).toBe( + "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + ); + }); + it("redacts and restores dynamic env catchall secrets when uiHints miss the path", () => { const hints: ConfigUiHints = { "some.other.path": { sensitive: true }, diff --git a/src/config/schema.hints.test.ts b/src/config/schema.hints.test.ts index 784ae8ed127..1b486b401b1 100644 --- a/src/config/schema.hints.test.ts +++ b/src/config/schema.hints.test.ts @@ -47,6 +47,8 @@ describe("isSensitiveConfigPath", () => { expect(isSensitiveConfigPath("channels.irc.nickserv.password")).toBe(true); expect(isSensitiveConfigPath("channels.feishu.encryptKey")).toBe(true); expect(isSensitiveConfigPath("channels.feishu.accounts.default.encryptKey")).toBe(true); + expect(isSensitiveConfigPath("channels.nostr.privateKey")).toBe(true); + expect(isSensitiveConfigPath("channels.nostr.accounts.default.privateKey")).toBe(true); }); }); diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index 68ece43845f..49339509d37 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -137,6 +137,7 @@ const SENSITIVE_PATTERNS = [ /secret/i, /api.?key/i, /encrypt.?key/i, + /private.?key/i, /serviceaccount(?:ref)?$/i, ];