VYPR
Medium severity6.5NVD Advisory· Published Apr 28, 2026· Updated May 1, 2026

CVE-2026-41385

CVE-2026-41385

Description

OpenClaw before 2026.3.31 stores Nostr privateKey as plaintext in configuration, allowing exposure through config.get method calls that bypass redaction mechanisms. Attackers can retrieve unredacted configuration data to obtain plaintext signing keys used for Nostr protocol operations.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
openclawnpm
< 2026.3.312026.3.31

Affected products

1
  • cpe:2.3:a:openclaw:openclaw:*:*:*:*:*:node.js:*:*
    Range: <2026.3.31

Patches

1
57700d716f66

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

https://github.com/openclaw/openclawVincent KocMar 31, 2026via ghsa
10 files changed · +200 10
  • CHANGELOG.md+1 0 modified
    @@ -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.
    
  • extensions/nostr/src/channel.test.ts+65 0 modified
    @@ -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,
    +      });
    +    });
    +  });
     });
    
  • extensions/nostr/src/config-schema.ts+2 1 modified
    @@ -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(),
    
  • extensions/nostr/src/setup-surface.ts+7 3 modified
    @@ -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(),
             };
           },
    
  • extensions/nostr/src/types.ts+7 5 modified
    @@ -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 {
    
  • src/config/bundled-channel-config-metadata.generated.ts+69 1 modified
    @@ -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",
    
  • src/config/redact-snapshot.schema.test.ts+22 0 modified
    @@ -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",
    +    );
    +  });
     });
    
  • src/config/redact-snapshot.test.ts+24 0 modified
    @@ -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 },
    
  • src/config/schema.hints.test.ts+2 0 modified
    @@ -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);
       });
     });
     
    
  • src/config/schema.hints.ts+1 0 modified
    @@ -137,6 +137,7 @@ const SENSITIVE_PATTERNS = [
       /secret/i,
       /api.?key/i,
       /encrypt.?key/i,
    +  /private.?key/i,
       /serviceaccount(?:ref)?$/i,
     ];
     
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.