mirror of https://github.com/openclaw/openclaw.git
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:
parent
efe9183f9d
commit
57700d716f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ const SENSITIVE_PATTERNS = [
|
|||
/secret/i,
|
||||
/api.?key/i,
|
||||
/encrypt.?key/i,
|
||||
/private.?key/i,
|
||||
/serviceaccount(?:ref)?$/i,
|
||||
];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue