fix(chatgpt-web): portable SHA3-512 for sentinel PoW under Electron/BoringSSL (#5531) (#5540)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-30 02:59:23 -03:00
committed by GitHub
parent 15fdfe200e
commit d10b929cc1
4 changed files with 250 additions and 3 deletions

View File

@@ -8,6 +8,7 @@
### 🔧 Bug Fixes
- **providers (chatgpt-web):** fix `502 ChatGPT sentinel failed: Digest method not supported` on the **Electron desktop app**, which made every `chatgpt-web/*` request fail. The sentinel proof-of-work hashed with native `createHash("sha3-512")`, but Electron's Node is built against **BoringSSL, which does not implement the SHA-3 family** (electron/electron#30530), so the digest threw at construction — the provider was unusable on the desktop build (works under plain Node/OpenSSL). The PoW now hashes through a new runtime-portable helper (`open-sse/utils/sha3-512.ts`) that prefers the native digest and transparently falls back to a dependency-free pure-JS Keccak-f[1600] when native SHA-3 is absent. The fallback is validated bit-for-bit against native `createHash("sha3-512")` (300 random inputs) and the published FIPS-202 known-answer vectors. Regression guards in `tests/unit/chatgpt-web-sha3-boringssl-5531.test.ts`. ([#5531](https://github.com/diegosouzapw/OmniRoute/issues/5531))
- **providers (bytez):** fix Bytez key validation ("Provider validation endpoint not supported") and the chat base URL, verified live with a real key. Bytez **is** OpenAI-compatible at `…/models/v2/openai/v1`, but the registry stored the bare `…/models/v2` base, so the validation chat-probe hit `…/models/v2/chat/completions``404` → the misleading "endpoint not supported". Two parts: (1) the registry `baseUrl` now carries the full OpenAI-compat chat path (`…/models/v2/openai/v1/chat/completions`); (2) key validation no longer uses a chat probe — a Bytez account only serves models explicitly added to its catalog, so even valid keys 404 on any model id. A dedicated `validateBytezProvider` instead probes the **auth-only** `GET …/models/v2/list/tasks` endpoint (`200` ⇒ valid, `401/403` ⇒ invalid), which is independent of catalog provisioning. Regression guard: `tests/unit/bytez-validation-5422.test.ts`. ([#5422](https://github.com/diegosouzapw/OmniRoute/issues/5422))
- **dashboard (provider add):** two provider-add UX fixes. (1) #5420 — the "Import Models" button now stays hidden for **tool-only providers** (web search / web fetch), not just `*-search` ids: `firecrawl` and `jina-reader` (declared `serviceKinds: ["webFetch"]`) previously showed an Import button that hit the `400 "does not support models listing"` route. A new capability check (`providerLacksModelListing` over the resolved serviceKinds) gates the section without ever hiding an LLM/media provider. (2) #5426 — Coze key validation no longer leaks the raw upstream envelope (`{code,msg,logId,from}`) into the UI; the Coze-shaped error becomes a friendly `Coze rejected the key: <msg> (code <n>)` message (scoped to `provider === "coze"` so no other provider is affected). Regression guards: `tests/unit/model-listing-capability-5420.test.ts`, `tests/unit/coze-validation-error-5426.test.ts`. ([#5420](https://github.com/diegosouzapw/OmniRoute/issues/5420), [#5426](https://github.com/diegosouzapw/OmniRoute/issues/5426))
- **providers (friendliai, novita):** fix two provider registry endpoints that rejected valid keys (verified live with real keys). **FriendliAI** pointed at `…/dedicated/v1/chat/completions`, which `403 Forbidden`s a serverless `flp_*` token — switched to `…/serverless/v1/chat/completions` (+ a serverless `modelsUrl`). **Novita** pointed at the legacy `…/v3/…` base with a typod model id `ai-ai/llama-3.1-8b-instruct` (both `404`) — switched to the OpenAI-compatible `…/openai/v1/…` base + the valid `meta-llama/llama-3.1-8b-instruct` id. Regression guard: `tests/unit/provider-endpoints-friendliai-novita.test.ts`. ([#5430](https://github.com/diegosouzapw/OmniRoute/issues/5430), [#5455](https://github.com/diegosouzapw/OmniRoute/issues/5455))

View File

@@ -20,6 +20,7 @@ import { describeChatGptWebHttpError } from "./chatgptWebErrors.ts";
import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
import { createHash, randomUUID, randomBytes } from "node:crypto";
import { sha3_512Hex } from "../utils/sha3-512.ts";
import {
tlsFetchChatGpt,
TlsClientUnavailableError,
@@ -885,9 +886,8 @@ async function solvePow(opts: PowOptions): Promise<string> {
cfg[3] = i;
const json = JSON.stringify(cfg);
const b64 = Buffer.from(json).toString("base64");
const hash = createHash("sha3-512")
.update(opts.seed + b64)
.digest("hex");
// Portable SHA3-512 — pure-JS fallback under Electron/BoringSSL (#5531).
const hash = sha3_512Hex(opts.seed + b64);
if (opts.target && hash.slice(0, opts.target.length) <= opts.target) {
return `${opts.prefix}${b64}`;
}

164
open-sse/utils/sha3-512.ts Normal file
View File

@@ -0,0 +1,164 @@
// Runtime-portable SHA3-512 (FIPS-202).
//
// The ChatGPT-Web sentinel proof-of-work (open-sse/executors/chatgpt-web.ts)
// hashes with SHA3-512. Node built against OpenSSL has it natively, but the
// **Electron desktop app** ships Node built against **BoringSSL, which does not
// implement the SHA-3 family** (electron/electron#30530). There,
// `createHash("sha3-512")` throws `Error: Digest method not supported`, so every
// chatgpt-web request fails with `502 ChatGPT sentinel failed: Digest method not
// supported` and the provider is unusable on the desktop app (#5531).
//
// This module prefers the native digest (fast path on servers / OpenSSL) and
// transparently falls back to a dependency-free pure-JS Keccak-f[1600] when the
// runtime's crypto lacks SHA-3. The pure-JS path is validated bit-for-bit
// against the native digest and the published FIPS-202 vectors in the unit test.
import { createHash } from "node:crypto";
// ─── Keccak-f[1600] (BigInt lanes — correctness-first; only the fallback runs it) ──
const MASK = (1n << 64n) - 1n;
// Round constants RC[0..23].
const RC: bigint[] = [
0x0000000000000001n,
0x0000000000008082n,
0x800000000000808an,
0x8000000080008000n,
0x000000000000808bn,
0x0000000080000001n,
0x8000000080008081n,
0x8000000000008009n,
0x000000000000008an,
0x0000000000000088n,
0x0000000080008009n,
0x000000008000000an,
0x000000008000808bn,
0x800000000000008bn,
0x8000000000008089n,
0x8000000000008003n,
0x8000000000008002n,
0x8000000000000080n,
0x000000000000800an,
0x800000008000000an,
0x8000000080008081n,
0x8000000000008080n,
0x0000000080000001n,
0x8000000080008008n,
];
// Rotation offsets r[x+5y] (rho step).
const ROT: number[] = [
0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14,
];
function rotl64(x: bigint, n: number): bigint {
if (n === 0) return x;
const bn = BigInt(n);
return ((x << bn) | (x >> (64n - bn))) & MASK;
}
function keccakF1600(s: bigint[]): void {
const C = new Array<bigint>(5);
const D = new Array<bigint>(5);
const B = new Array<bigint>(25);
for (let round = 0; round < 24; round++) {
// θ
for (let x = 0; x < 5; x++) C[x] = s[x] ^ s[x + 5] ^ s[x + 10] ^ s[x + 15] ^ s[x + 20];
for (let x = 0; x < 5; x++) D[x] = C[(x + 4) % 5] ^ rotl64(C[(x + 1) % 5], 1);
for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) s[x + 5 * y] ^= D[x];
// ρ + π
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
B[y + 5 * ((2 * x + 3 * y) % 5)] = rotl64(s[x + 5 * y], ROT[x + 5 * y]);
}
}
// χ
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
s[x + 5 * y] = B[x + 5 * y] ^ (~B[((x + 1) % 5) + 5 * y] & MASK & B[((x + 2) % 5) + 5 * y]);
}
}
// ι
s[0] ^= RC[round];
}
}
// SHA3-512: rate r = 576 bits (72 bytes / 9 lanes), capacity 1024, output 64 bytes.
const RATE_BYTES = 72;
function sha3_512Bytes(msg: Uint8Array): Uint8Array {
const s: bigint[] = new Array<bigint>(25).fill(0n);
// FIPS-202 pad10*1 with SHA-3 domain separation (first pad byte 0x06, last |= 0x80).
const padLen = RATE_BYTES - (msg.length % RATE_BYTES);
const padded = new Uint8Array(msg.length + padLen);
padded.set(msg);
padded[msg.length] = 0x06;
padded[padded.length - 1] |= 0x80;
// Absorb (little-endian lanes).
for (let off = 0; off < padded.length; off += RATE_BYTES) {
for (let i = 0; i < RATE_BYTES / 8; i++) {
let lane = 0n;
for (let b = 0; b < 8; b++) lane |= BigInt(padded[off + i * 8 + b]) << BigInt(8 * b);
s[i] ^= lane;
}
keccakF1600(s);
}
// Squeeze 64 bytes (8 lanes — fits in one rate block).
const out = new Uint8Array(64);
for (let i = 0; i < 8; i++) {
const lane = s[i];
for (let b = 0; b < 8; b++) out[i * 8 + b] = Number((lane >> BigInt(8 * b)) & 0xffn);
}
return out;
}
function toBytes(input: string | Uint8Array): Uint8Array {
return typeof input === "string" ? new Uint8Array(Buffer.from(input, "utf8")) : input;
}
/** Pure-JS SHA3-512 hex digest. Always runs the vendored Keccak (used as the fallback). */
export function sha3_512HexJs(input: string | Uint8Array): string {
return Buffer.from(sha3_512Bytes(toBytes(input))).toString("hex");
}
type NativeHasher = (data: Uint8Array) => string;
// undefined = not yet probed; null = native SHA-3 unavailable (BoringSSL/Electron).
let nativeHasher: NativeHasher | null | undefined;
function detectNative(): NativeHasher | null {
try {
// Construct + digest once — Electron/BoringSSL throws here, not lazily.
createHash("sha3-512").update(Buffer.alloc(0)).digest("hex");
return (data) => createHash("sha3-512").update(data).digest("hex");
} catch {
return null;
}
}
/**
* SHA3-512 hex digest that works on every runtime: native (OpenSSL) where
* available, pure-JS Keccak fallback where the crypto backend lacks SHA-3
* (Electron/BoringSSL — #5531). Capability is probed once and cached.
*/
export function sha3_512Hex(input: string | Uint8Array): string {
const data = toBytes(input);
if (nativeHasher === undefined) nativeHasher = detectNative();
if (nativeHasher) {
try {
return nativeHasher(data);
} catch {
nativeHasher = null; // became unavailable mid-flight — defensive
}
}
return sha3_512HexJs(data);
}
/** Test seam: force the native capability state (null = simulate BoringSSL, undefined = re-probe). */
export function __setSha3NativeForTesting(state: NativeHasher | null | undefined): void {
nativeHasher = state;
}

View File

@@ -0,0 +1,82 @@
// #5531 — chatgpt-web sentinel PoW crashes on the Electron desktop app with
// "Digest method not supported" because Electron's BoringSSL lacks SHA-3
// (electron/electron#30530). The PoW must hash through a runtime-portable
// SHA3-512 that falls back to a pure-JS Keccak when native SHA-3 is absent.
import test from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const { sha3_512Hex, sha3_512HexJs, __setSha3NativeForTesting } =
await import("../../open-sse/utils/sha3-512.ts");
// FIPS-202 known-answer vectors for SHA3-512.
const FIPS: Record<string, string> = {
"":
"a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a6" +
"15b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26",
abc:
"b751850b1a57168a5693cd924b6b096e08f621827444f70d884f5d0240d2712e" +
"10e116e9192af3c91a7ec57647e3934057340b4cf408d5a56592f8274eec53f0",
};
function nativeSha3Available(): boolean {
try {
createHash("sha3-512").update(Buffer.alloc(0)).digest("hex");
return true;
} catch {
return false;
}
}
test("pure-JS SHA3-512 matches the FIPS-202 known-answer vectors", () => {
for (const [msg, want] of Object.entries(FIPS)) {
assert.equal(sha3_512HexJs(msg), want, `FIPS-202 vector mismatch for "${msg}"`);
}
});
test("pure-JS SHA3-512 is bit-identical to native createHash('sha3-512') on 300 random inputs", () => {
if (!nativeSha3Available()) {
// Runtime without native SHA-3 (e.g. an Electron CI) — FIPS vectors already cover correctness.
return;
}
for (let i = 0; i < 300; i++) {
const len = (i * 7) % 200; // spans multi-block (>72B) and exact-block-boundary cases
const buf = Buffer.alloc(len);
for (let j = 0; j < len; j++) buf[j] = (i * 31 + j * 17) & 0xff;
const native = createHash("sha3-512").update(buf).digest("hex");
assert.equal(sha3_512HexJs(buf), native, `mismatch vs native at len=${len}`);
}
});
test("sha3_512Hex falls back to pure-JS when native SHA-3 is unavailable (Electron/BoringSSL sim) — #5531", () => {
__setSha3NativeForTesting(null); // simulate BoringSSL: createHash('sha3-512') would throw
try {
assert.equal(sha3_512Hex("abc"), FIPS.abc);
assert.equal(sha3_512Hex(""), FIPS[""]);
assert.equal(sha3_512Hex(Buffer.from("abc")), FIPS.abc);
} finally {
__setSha3NativeForTesting(undefined); // restore auto-detect
}
});
test("sha3_512Hex uses the native digest where available (parity with fallback)", () => {
if (!nativeSha3Available()) return;
__setSha3NativeForTesting(undefined); // force re-probe → native
assert.equal(sha3_512Hex("abc"), FIPS.abc);
assert.equal(sha3_512Hex("abc"), sha3_512HexJs("abc"));
});
test("chatgpt-web PoW routes SHA3-512 through the portable helper, not inline createHash (#5531 guard)", async () => {
const execPath = fileURLToPath(
new URL("../../open-sse/executors/chatgpt-web.ts", import.meta.url)
);
const src = await readFile(execPath, "utf8");
assert.ok(
!/createHash\(\s*["']sha3-512["']\s*\)/.test(src),
"PoW must NOT call native createHash('sha3-512') inline — it crashes under Electron/BoringSSL"
);
assert.ok(/sha3_512Hex\s*\(/.test(src), "chatgpt-web PoW must hash via sha3_512Hex()");
});