diff --git a/src/agents/auth-profiles/sqlite.ts b/src/agents/auth-profiles/sqlite.ts index 81ba05a36ea0..498e734892d3 100644 --- a/src/agents/auth-profiles/sqlite.ts +++ b/src/agents/auth-profiles/sqlite.ts @@ -3,10 +3,10 @@ * The public helpers expose raw JSON payloads so normalization stays in the * store/state layers that own compatibility rules. */ -import { createHash } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import type { DatabaseSync } from "node:sqlite"; +import { sha256HexPrefix } from "../../infra/crypto-digest.js"; import { clearNodeSqliteKyselyCacheForDatabase, executeSqliteQuerySync, @@ -46,8 +46,7 @@ function inferAgentIdFromDir(agentDir: string): string { return parent; } } - const hash = createHash("sha256").update(normalized).digest("hex").slice(0, 12); - return `custom-${hash}`; + return `custom-${sha256HexPrefix(normalized, 12)}`; } // The auth database lives in the agent dir and shares the openclaw-agent schema diff --git a/src/agents/chutes-oauth.ts b/src/agents/chutes-oauth.ts index 530c002dd669..b145abd4d5a3 100644 --- a/src/agents/chutes-oauth.ts +++ b/src/agents/chutes-oauth.ts @@ -2,8 +2,9 @@ * Implements Chutes OAuth PKCE, callback parsing, token exchange, and refresh * for agent model authentication. */ -import { createHash, randomBytes } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { sha256Base64Url } from "../infra/crypto-digest.js"; import { resolveExpiresAtMsFromDurationSeconds } from "../infra/parse-finite-number.js"; import type { OAuthCredentials } from "../llm/oauth.js"; import { readProviderJsonResponse, readResponseTextLimited } from "./provider-http-errors.js"; @@ -40,7 +41,7 @@ type ChutesStoredOAuth = OAuthCredentials & { /** Generates a PKCE verifier/challenge pair for Chutes login. */ export function generateChutesPkce(): ChutesPkce { const verifier = randomBytes(32).toString("hex"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); + const challenge = sha256Base64Url(verifier); return { verifier, challenge }; } diff --git a/src/agents/embedded-agent-helpers/openai.ts b/src/agents/embedded-agent-helpers/openai.ts index 7f9d5f993193..a4c19eb75cdd 100644 --- a/src/agents/embedded-agent-helpers/openai.ts +++ b/src/agents/embedded-agent-helpers/openai.ts @@ -1,7 +1,7 @@ /** * Normalizes OpenAI Responses reasoning/tool-call history for safe replay. */ -import { createHash } from "node:crypto"; +import { sha256HexPrefix } from "../../infra/crypto-digest.js"; import type { AgentMessage } from "../runtime/index.js"; type OpenAIThinkingBlock = { @@ -95,7 +95,7 @@ function isOpenAIToolCallType(type: unknown): boolean { } function shortOpenAIResponsesIdHash(id: string): string { - return createHash("sha256").update(id).digest("hex").slice(0, 10); + return sha256HexPrefix(id, 10); } function sanitizeOpenAIResponsesIdTail(value: string): string { diff --git a/src/agents/openai-transport-stream.ts b/src/agents/openai-transport-stream.ts index 13199cffc6de..c8635a04af34 100644 --- a/src/agents/openai-transport-stream.ts +++ b/src/agents/openai-transport-stream.ts @@ -4,7 +4,7 @@ * Handles Chat Completions, Responses, Azure variants, tool-call replay, reasoning events, and * provider-specific payload policy before converting SDK streams into OpenClaw assistant events. */ -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; import OpenAI, { AzureOpenAI } from "openai"; @@ -21,6 +21,7 @@ import type { ResponseReasoningItem, } from "openai/resources/responses/responses.js"; import type { ModelCompatConfig } from "../config/types.models.js"; +import { sha256Hex, sha256HexPrefix } from "../infra/crypto-digest.js"; import { getEnvApiKey } from "../llm/env-api-keys.js"; import { calculateCost } from "../llm/model-utils.js"; import { resolveAzureDeploymentNameFromMap } from "../llm/providers/azure-deployment-map.js"; @@ -1023,7 +1024,7 @@ export function resolveAzureOpenAIApiVersion(env = process.env): string { } function shortHash(value: string): string { - return createHash("sha256").update(value).digest("hex").slice(0, 16); + return sha256HexPrefix(value, 16); } function normalizeResponsesReplayItemId( @@ -1385,20 +1386,18 @@ function buildOpenAIStrictToolDowngradeDiagnosticKey( diagnostics: ReturnType, context: { transport: "responses" | "completions"; model: OpenAIModeModel }, ): string { - return createHash("sha256") - .update( - JSON.stringify({ - transport: context.transport, - provider: context.model.provider ?? null, - model: context.model.id ?? null, - diagnostics: diagnostics.map((entry) => ({ - toolIndex: entry.toolIndex, - toolName: entry.toolName ?? null, - violations: entry.violations, - })), - }), - ) - .digest("hex"); + return sha256Hex( + JSON.stringify({ + transport: context.transport, + provider: context.model.provider ?? null, + model: context.model.id ?? null, + diagnostics: diagnostics.map((entry) => ({ + toolIndex: entry.toolIndex, + toolName: entry.toolName ?? null, + violations: entry.violations, + })), + }), + ); } function shouldLogOpenAIStrictToolDowngradeDiagnostic( diff --git a/src/agents/tool-call-id.ts b/src/agents/tool-call-id.ts index 32f3ad35c2ee..ead0393430d7 100644 --- a/src/agents/tool-call-id.ts +++ b/src/agents/tool-call-id.ts @@ -3,7 +3,7 @@ * * Keeps provider-specific id formats replay-safe while preserving allowed native ids. */ -import { createHash } from "node:crypto"; +import { sha256HexPrefix } from "../infra/crypto-digest.js"; import type { AgentMessage } from "./runtime/index.js"; import { isThinkingLikeBlock } from "./thinking-block.js"; import { isAllowedToolCallName, normalizeAllowedToolNames } from "./tool-call-shared.js"; @@ -218,7 +218,7 @@ export function isValidCloudCodeAssistToolId(id: string, mode: ToolCallIdMode = } function shortHash(text: string, length = 8): string { - return createHash("sha256").update(text).digest("hex").slice(0, length); + return sha256HexPrefix(text, length); } function isNativeAnthropicToolUseId(id: string): boolean { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index d615f30b8d99..406ddc30cf06 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -3,14 +3,12 @@ * * Sends, edits, reacts to, polls, and routes messages through channel plugins and Gateway-backed actions. */ -import { createHash } from "node:crypto"; import { normalizeOptionalString, normalizeOptionalStringifiedId, } from "@openclaw/normalization-core/string-coerce"; import { sortUniqueStrings, uniqueValues } from "@openclaw/normalization-core/string-normalization"; import { Type, type TSchema } from "typebox"; -import { createAbortError } from "../../infra/abort-signal.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES, @@ -45,6 +43,8 @@ import { getBootEchoContextForSession, stripBootEchoFromOutboundText, } from "../../gateway/boot-echo-guard.js"; +import { createAbortError } from "../../infra/abort-signal.js"; +import { sha256Base64UrlPrefix } from "../../infra/crypto-digest.js"; import { parseInteractiveParam, parseJsonMessageParam, @@ -147,7 +147,7 @@ function buildMessageToolDeliveryFingerprint(params: { params: stripMessageToolIdempotencyEnvelope(params.params), }), ); - return createHash("sha256").update(canonical).digest("base64url").slice(0, 24); + return sha256Base64UrlPrefix(canonical, 24); } function buildMessageToolAutogeneratedIdempotencyKey(params: { diff --git a/src/config/runtime-snapshot.ts b/src/config/runtime-snapshot.ts index 089d22cdbd03..81e04b418fe0 100644 --- a/src/config/runtime-snapshot.ts +++ b/src/config/runtime-snapshot.ts @@ -1,5 +1,5 @@ // Produces redacted runtime config snapshots for diagnostics and UI surfaces. -import { createHash } from "node:crypto"; +import { sha256Base64Url } from "../infra/crypto-digest.js"; import type { OpenClawConfig } from "./types.js"; export type RuntimeConfigSnapshotRefreshOptions = { @@ -121,7 +121,7 @@ function configSnapshotsMatch(left: OpenClawConfig, right: OpenClawConfig): bool } export function hashRuntimeConfigValue(value: OpenClawConfig): string { - return createHash("sha256").update(stableConfigStringify(value)).digest("base64url"); + return sha256Base64Url(stableConfigStringify(value)); } function createRuntimeConfigSnapshotMetadata( diff --git a/src/gateway/server-methods/device-management-security.ts b/src/gateway/server-methods/device-management-security.ts index 2b4d2fad349a..f44ecb69b64c 100644 --- a/src/gateway/server-methods/device-management-security.ts +++ b/src/gateway/server-methods/device-management-security.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { sha256HexPrefix } from "../../infra/crypto-digest.js"; import { emitTrustedSecurityEvent, type DiagnosticSecurityEventInput, @@ -12,7 +12,7 @@ function hashDeviceSecurityId(value: string | undefined): string | undefined { if (!normalized) { return undefined; } - return `sha256:${createHash("sha256").update(normalized).digest("hex").slice(0, 12)}`; + return `sha256:${sha256HexPrefix(normalized, 12)}`; } export function emitDeviceManagementSecurityEvent(params: { diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 9e2493ba04de..ca040319ff4b 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -1,5 +1,4 @@ // WebSocket message handler validates frames, dispatches gateway RPCs, manages pairing, and reports responses. -import { createHash } from "node:crypto"; import fs from "node:fs"; import type { IncomingMessage } from "node:http"; import os from "node:os"; @@ -42,6 +41,7 @@ import { } from "../../../../packages/gateway-protocol/src/startup-unavailable.js"; import { getRuntimeConfig } from "../../../config/io.js"; import { resolveStateDir } from "../../../config/paths.js"; +import { sha256HexPrefix } from "../../../infra/crypto-digest.js"; import { getBoundDeviceBootstrapProfile, getDeviceBootstrapTokenProfile, @@ -200,7 +200,7 @@ function hashGatewaySecurityId(value: string | undefined): string | undefined { if (!normalized) { return undefined; } - return `sha256:${createHash("sha256").update(normalized).digest("hex").slice(0, 12)}`; + return `sha256:${sha256HexPrefix(normalized, 12)}`; } function emitGatewayAuthSecurityEvent(params: { diff --git a/src/gateway/server/ws-shared-generation.ts b/src/gateway/server/ws-shared-generation.ts index cf981b31f736..76cd6988e13b 100644 --- a/src/gateway/server/ws-shared-generation.ts +++ b/src/gateway/server/ws-shared-generation.ts @@ -1,6 +1,6 @@ // WebSocket shared-session generation hashes gateway auth inputs so clients can detect credential rotation. -import { createHash } from "node:crypto"; import type { GatewayTrustedProxyConfig } from "../../config/types.gateway.js"; +import { sha256Base64Url } from "../../infra/crypto-digest.js"; import type { ResolvedGatewayAuth } from "../auth.js"; function resolveSharedSecret( @@ -40,21 +40,16 @@ export function resolveSharedGatewaySessionGeneration( ): string | undefined { const shared = resolveSharedSecret(auth); if (shared) { - return createHash("sha256") - .update(`${shared.mode}\u0000${shared.secret}`, "utf8") - .digest("base64url"); + return sha256Base64Url(`${shared.mode}\u0000${shared.secret}`); } if (auth.mode === "trusted-proxy") { - return createHash("sha256") - .update( - JSON.stringify({ - mode: auth.mode, - trustedProxy: normalizeTrustedProxyConfig(auth.trustedProxy), - trustedProxies: [...(trustedProxies ?? [])].toSorted(), - }), - "utf8", - ) - .digest("base64url"); + return sha256Base64Url( + JSON.stringify({ + mode: auth.mode, + trustedProxy: normalizeTrustedProxyConfig(auth.trustedProxy), + trustedProxies: [...(trustedProxies ?? [])].toSorted(), + }), + ); } return undefined; } diff --git a/src/gateway/talk-handoff.ts b/src/gateway/talk-handoff.ts index 2e033f3d7ae0..49f7f246b01e 100644 --- a/src/gateway/talk-handoff.ts +++ b/src/gateway/talk-handoff.ts @@ -1,6 +1,6 @@ // Gateway Talk handoff registry. // Manages short-lived browser Talk rooms, tokens, events, and turn ownership. -import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { asDateTimestampMs, isFutureDateTimestampMs, @@ -8,6 +8,7 @@ import { resolveExpiresAtMsFromDurationMs, } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { sha256Base64Url } from "../infra/crypto-digest.js"; import { recordTalkObservabilityEvent } from "../talk/observability.js"; import { createTalkSessionController, @@ -324,7 +325,7 @@ function pruneExpiredTalkHandoffs(now = Date.now()): void { } function hashTalkHandoffToken(token: string): string { - return createHash("sha256").update(token).digest("base64url"); + return sha256Base64Url(token); } function toPublicTalkHandoffRecord(record: TalkHandoffRecord): TalkHandoffPublicRecord { diff --git a/src/infra/crypto-digest.test.ts b/src/infra/crypto-digest.test.ts index 170a0c7c0353..cfb1dd97d204 100644 --- a/src/infra/crypto-digest.test.ts +++ b/src/infra/crypto-digest.test.ts @@ -2,7 +2,14 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempDir } from "../test-helpers/temp-dir.js"; -import { sha256Base64, sha256File, sha256Hex, sha256HexPrefix } from "./crypto-digest.js"; +import { + sha256Base64, + sha256Base64Url, + sha256Base64UrlPrefix, + sha256File, + sha256Hex, + sha256HexPrefix, +} from "./crypto-digest.js"; const HOSTILE_BYTES = Uint8Array.from([0, 255, 128, 195, 40, 226, 40, 161]); const HOSTILE_BYTES_SHA256 = "bd88bda48025bbcf78712d1ff89b55b1cca10c3a9b36c275af350f52b5987902"; @@ -15,6 +22,7 @@ describe("crypto digest helpers", () => { "75781ac975ff76899629e996d8e96aa5e89db77315473d8b3281cb8aa700b2e6", ); expect(sha256Base64(input)).toBe("dXgayXX/domWKemW2Olqpeidt3MVRz2LMoHLiqcAsuY="); + expect(sha256Base64Url(input)).toBe("dXgayXX_domWKemW2Olqpeidt3MVRz2LMoHLiqcAsuY"); }); it("hashes arbitrary bytes without text decoding", () => { @@ -24,6 +32,7 @@ describe("crypto digest helpers", () => { it("returns an exact hexadecimal prefix", () => { expect(sha256HexPrefix(HOSTILE_BYTES, 12)).toBe(HOSTILE_BYTES_SHA256.slice(0, 12)); + expect(sha256Base64UrlPrefix(HOSTILE_BYTES, 12)).toBe("vYi9pIAlu894"); }); it("streams file bytes through the same digest contract", async () => { diff --git a/src/infra/crypto-digest.ts b/src/infra/crypto-digest.ts index 19a86a5aebf1..8ffd7812e6a0 100644 --- a/src/infra/crypto-digest.ts +++ b/src/infra/crypto-digest.ts @@ -11,6 +11,14 @@ export function sha256Base64(input: DigestInput): string { return createHash("sha256").update(input).digest("base64"); } +export function sha256Base64Url(input: DigestInput): string { + return createHash("sha256").update(input).digest("base64url"); +} + +export function sha256Base64UrlPrefix(input: DigestInput, length: number): string { + return sha256Base64Url(input).slice(0, length); +} + export function sha256HexPrefix(input: DigestInput, length: number): string { return sha256Hex(input).slice(0, length); } diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 5807a466e1a3..0e4d5bb7b161 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -1,5 +1,5 @@ // Manages exec approval policy, allowlist entries, and host targeting. -import crypto from "node:crypto"; +import { randomBytes } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { @@ -10,6 +10,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; import type { CommandExplanationSummary } from "./command-analysis/explain.js"; +import { sha256Hex, sha256HexPrefix } from "./crypto-digest.js"; import { type AllowAlwaysPattern, resolveAllowAlwaysPatternEntries, @@ -302,10 +303,7 @@ const EXEC_APPROVALS_FILE = "exec-approvals.json"; const EXEC_APPROVALS_SOCKET = "exec-approvals.sock"; function hashExecApprovalsRaw(raw: string | null): string { - return crypto - .createHash("sha256") - .update(raw ?? "") - .digest("hex"); + return sha256Hex(raw ?? ""); } function resolveExecApprovalsStateDir(env: NodeJS.ProcessEnv = process.env): { @@ -796,7 +794,7 @@ export function mergeExecApprovalsSocketDefaults(params: { } function generateToken(): string { - return crypto.randomBytes(24).toString("base64url"); + return randomBytes(24).toString("base64url"); } export function readExecApprovalsSnapshot(): ExecApprovalsSnapshot { @@ -1348,13 +1346,11 @@ export function hasDurableExecApproval(params: { // already hold `=command:` entries in this format; changing the input // silently orphans every persisted exact-command grant. function buildDurableCommandApprovalPattern(commandText: string): string { - const digest = crypto.createHash("sha256").update(commandText).digest("hex").slice(0, 16); - return `=command:${digest}`; + return `=command:${sha256HexPrefix(commandText, 16)}`; } function buildNodeCommandApprovalPattern(commandText: string): string { - const digest = crypto.createHash("sha256").update(commandText).digest("hex").slice(0, 16); - return `=node-command:${digest}`; + return `=node-command:${sha256HexPrefix(commandText, 16)}`; } export function hasNodeCommandAllowAlwaysMarker(params: { diff --git a/src/plugins/git-install.ts b/src/plugins/git-install.ts index ce0e927d945f..49fba6ba38fa 100644 --- a/src/plugins/git-install.ts +++ b/src/plugins/git-install.ts @@ -1,11 +1,11 @@ /** Parses, clones, verifies, and installs plugin packages from Git specs. */ import "../infra/fs-safe-defaults.js"; -import { createHash } from "node:crypto"; import path from "node:path"; import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url"; import { hasHttpUrlPrefix } from "@openclaw/net-policy/url-protocol"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sanitizeForLog } from "../../packages/terminal-core/src/ansi.js"; +import { sha256HexPrefix } from "../infra/crypto-digest.js"; import { pathExists } from "../infra/fs-safe.js"; import { withTempDir } from "../infra/install-source-utils.js"; import { replaceDirectoryAtomic } from "../infra/replace-file.js"; @@ -238,8 +238,7 @@ function resolveGitInstallRepoDir(params: { }): string { const gitRoot = params.gitDir ? resolveUserPath(params.gitDir) : resolveDefaultPluginGitDir(); const redactedSpec = redactSensitiveUrlLikeString(params.source.normalizedSpec); - const hash = createHash("sha256").update(redactedSpec).digest("hex").slice(0, 16); - return path.join(gitRoot, `git-${hash}`, "repo"); + return path.join(gitRoot, `git-${sha256HexPrefix(redactedSpec, 16)}`, "repo"); } async function replaceManagedGitRepo(params: { diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 305fafa1dc89..fbc00342d11e 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -1,5 +1,5 @@ // Installs plugins from package specs, local paths, and catalogs. -import { createHash, randomUUID } from "node:crypto"; +import { randomUUID } from "node:crypto"; import { constants as fsConstants, type Dirent } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; @@ -7,6 +7,7 @@ import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { satisfiesPluginApiRange } from "../infra/clawhub.js"; +import { sha256HexPrefix } from "../infra/crypto-digest.js"; import { packageNameMatchesId } from "../infra/install-safe-path.js"; import { resolveNpmPackArchiveMetadata, @@ -1838,7 +1839,7 @@ async function stageNpmPackArchiveInManagedRoot(params: { > { const archiveStoreDir = path.join(params.npmRoot, MANAGED_NPM_PACK_ARCHIVE_DIR); const identity = params.integrity ?? params.shasum ?? params.tarballName; - const identitySlug = createHash("sha256").update(identity).digest("hex").slice(0, 16); + const identitySlug = sha256HexPrefix(identity, 16); const packageSlug = safePluginInstallFileName(params.packageName) || "plugin"; const versionSlug = safePluginInstallFileName(params.version ?? "pack") || "pack"; const archiveFileName = `${packageSlug}-${versionSlug}-${identitySlug}.tgz`;