fix(config): redact Nostr privateKey in config views (#58177)

* wip(config): preserve nostr redaction progress

* fix(config): add private key redaction fallback

* fix(config): align nostr privateKey secret input handling

* fix(config): require resolved nostr private keys
This commit is contained in:
Vincent Koc 2026-03-31 19:55:03 +09:00 committed by GitHub
parent efe9183f9d
commit 57700d716f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 200 additions and 10 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string | number>;
@ -49,9 +50,10 @@ export function listNostrAccountIds(cfg: OpenClawConfig): string[] {
const nostrCfg = (cfg.channels as Record<string, unknown> | 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 {

View File

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

View File

@ -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<string, Record<string, unknown>>;
expect(channels.nostr.privateKey).toBe(REDACTED_SENTINEL);
const restored = restoreRedactedValues(result.config, snapshot.config, hints);
expect(restored.channels.nostr.privateKey).toBe(
"nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5",
);
});
});

View File

@ -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<string, Record<string, unknown>>;
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 },

View File

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

View File

@ -137,6 +137,7 @@ const SENSITIVE_PATTERNS = [
/secret/i,
/api.?key/i,
/encrypt.?key/i,
/private.?key/i,
/serviceaccount(?:ref)?$/i,
];