fix: verify OPKSSH binary integrity (#1318)

This commit is contained in:
ZacharyZcR
2026-08-24 07:58:09 +08:00
committed by GitHub
parent 2de9bb236b
commit f0cb81c3b5
3 changed files with 188 additions and 21 deletions
+5 -2
View File
@@ -41,17 +41,20 @@ RUN npm run build:backend
FROM node:24-slim AS opkssh-downloader
ARG TARGETARCH
ARG OPKSSH_VERSION=v0.16.0
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
ARG OPKSSH_SHA256_ARM64=9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44
WORKDIR /opkssh
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN case "$TARGETARCH" in \
amd64) OPKSSH_ARCH=amd64 ;; \
arm64) OPKSSH_ARCH=arm64 ;; \
amd64) OPKSSH_ARCH=amd64; OPKSSH_SHA256="$OPKSSH_SHA256_AMD64" ;; \
arm64) OPKSSH_ARCH=arm64; OPKSSH_SHA256="$OPKSSH_SHA256_ARM64" ;; \
*) echo "Unsupported architecture: $TARGETARCH" && exit 1 ;; \
esac && \
curl -fSL -o "opkssh-linux-${OPKSSH_ARCH}" \
"https://github.com/openpubkey/opkssh/releases/download/${OPKSSH_VERSION}/opkssh-linux-${OPKSSH_ARCH}" && \
echo "$OPKSSH_SHA256 opkssh-linux-${OPKSSH_ARCH}" | sha256sum -c - && \
chmod 755 "opkssh-linux-${OPKSSH_ARCH}" && \
echo -n "$OPKSSH_VERSION" > version.txt
@@ -0,0 +1,81 @@
import crypto from "crypto";
import { promises as fs } from "fs";
import os from "os";
import path from "path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OPKSSHBinaryManager } from "../../utils/opkssh-binary-manager.js";
const binaryName = "opkssh-linux-amd64";
let dataDir: string;
beforeEach(async () => {
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "termix-opkssh-"));
process.env.DATA_DIR = dataDir;
process.env.OPKSSH_VERSION = "v-test";
});
afterEach(async () => {
vi.unstubAllGlobals();
delete process.env.DATA_DIR;
delete process.env.OPKSSH_VERSION;
delete process.env.OPKSSH_SHA256;
await fs.rm(dataDir, { recursive: true, force: true });
});
function mockRelease(binary: Buffer): void {
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
tag_name: "v-test",
assets: [
{ name: binaryName, browser_download_url: "https://asset.test" },
],
}),
{ status: 200 },
),
)
.mockResolvedValueOnce(new Response(binary, { status: 200 })),
);
}
describe("OPKSSHBinaryManager download integrity", () => {
it("installs a release only after its configured checksum matches", async () => {
const binary = Buffer.from("verified-opkssh-binary");
process.env.OPKSSH_SHA256 = crypto
.createHash("sha256")
.update(binary)
.digest("hex");
mockRelease(binary);
await OPKSSHBinaryManager.downloadBinary();
const installDir = path.join(dataDir, "opkssh");
expect(await fs.readFile(path.join(installDir, binaryName))).toEqual(
binary,
);
expect(
await fs.readFile(path.join(installDir, "version.txt"), "utf8"),
).toBe("v-test");
expect(
await fs.readFile(path.join(installDir, "checksum.txt"), "utf8"),
).toBe(process.env.OPKSSH_SHA256);
});
it("rejects a mismatched release without replacing the installed binary", async () => {
const installDir = path.join(dataDir, "opkssh");
const binaryPath = path.join(installDir, binaryName);
await fs.mkdir(installDir, { recursive: true });
await fs.writeFile(binaryPath, "known-good");
process.env.OPKSSH_SHA256 = "0".repeat(64);
mockRelease(Buffer.from("tampered"));
await expect(OPKSSHBinaryManager.downloadBinary()).rejects.toThrow(
"checksum mismatch",
);
expect(await fs.readFile(binaryPath, "utf8")).toBe("known-good");
});
});
+102 -19
View File
@@ -1,10 +1,26 @@
import { getErrorMessage } from "./error-message.js";
import { createWriteStream, promises as fs } from "fs";
import crypto from "crypto";
import path from "path";
import { pipeline } from "stream/promises";
import { systemLogger } from "./logger.js";
const OPKSSH_REPO = "openpubkey/opkssh";
const DEFAULT_OPKSSH_VERSION = "v0.16.0";
const DEFAULT_CHECKSUMS: Record<string, string> = {
"opkssh-linux-amd64":
"c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf",
"opkssh-linux-arm64":
"9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44",
"opkssh-osx-amd64":
"e1ccddb4a73c7dd24e0677e9c933462b954dde9a151fcd96c8ee7ba83bc3f146",
"opkssh-osx-arm64":
"be279812cc4d44a28f8cb6eef4b13515fae16b6d00ae61e21630e0c061b02cbd",
"opkssh-windows-amd64.exe":
"db8991ceaac7ac224b704510ca6fba2114998291d4283de4bc2d3b8efa66ad07",
"opkssh-windows-arm64.exe":
"c35352dc2d12ef3775b280aa85dc1e97ff90cd8ad4beb62c3e2357fdf80ba5af",
};
function getBinaryDir(): string {
const dataDir =
@@ -16,6 +32,65 @@ function getVersionFile(): string {
return path.join(getBinaryDir(), "version.txt");
}
function getChecksumFile(): string {
return path.join(getBinaryDir(), "checksum.txt");
}
function getTrustedRelease(binaryName: string): {
version: string;
checksum: string;
} {
const version = process.env.OPKSSH_VERSION || DEFAULT_OPKSSH_VERSION;
const configuredChecksum = process.env.OPKSSH_SHA256?.trim().toLowerCase();
const checksum =
version === DEFAULT_OPKSSH_VERSION
? DEFAULT_CHECKSUMS[binaryName]
: configuredChecksum;
if (!checksum || !/^[0-9a-f]{64}$/.test(checksum)) {
throw new Error(
`OPKSSH ${version} has no trusted SHA-256 checksum for ${binaryName}`,
);
}
return { version, checksum };
}
async function sha256File(filePath: string): Promise<string> {
const contents = await fs.readFile(filePath);
return crypto.createHash("sha256").update(contents).digest("hex");
}
async function verifyChecksum(
filePath: string,
expectedChecksum: string,
): Promise<void> {
const actualChecksum = await sha256File(filePath);
if (actualChecksum !== expectedChecksum) {
throw new Error(`OPKSSH checksum mismatch for ${path.basename(filePath)}`);
}
}
async function replaceBinary(
temporaryPath: string,
binaryPath: string,
): Promise<void> {
const backupPath = `${binaryPath}.previous`;
let backedUp = false;
try {
await fs.rename(binaryPath, backupPath);
backedUp = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
try {
await fs.rename(temporaryPath, binaryPath);
if (backedUp) await fs.rm(backupPath, { force: true });
} catch (error) {
if (backedUp) await fs.rename(backupPath, binaryPath);
throw error;
}
}
function getBundledDir(): string {
return (
process.env.OPKSSH_BUNDLED_DIR || path.join(process.cwd(), "opkssh-bundled")
@@ -45,6 +120,10 @@ export class OPKSSHBinaryManager {
try {
await fs.access(expectedPath);
const storedChecksum = (await fs.readFile(getChecksumFile(), "utf8"))
.trim()
.toLowerCase();
await verifyChecksum(expectedPath, storedChecksum);
const needsUpdate = await this.checkForUpdate();
if (needsUpdate) {
systemLogger.info("Newer OPKSSH version available, updating...", {
@@ -76,22 +155,17 @@ export class OPKSSHBinaryManager {
): Promise<boolean> {
const binaryName = this.getBinaryName();
const bundledPath = path.join(getBundledDir(), binaryName);
const bundledVersionFile = path.join(getBundledDir(), "version.txt");
const { version, checksum } = getTrustedRelease(binaryName);
try {
await fs.access(bundledPath);
await verifyChecksum(bundledPath, checksum);
await fs.mkdir(getBinaryDir(), { recursive: true });
await fs.copyFile(bundledPath, expectedPath);
await fs.chmod(expectedPath, 0o755);
await fs.writeFile(getChecksumFile(), checksum, "utf8");
try {
const bundledVersion = (
await fs.readFile(bundledVersionFile, "utf8")
).trim();
await fs.writeFile(getVersionFile(), bundledVersion, "utf8");
} catch {
// Bundled version file is optional
}
await fs.writeFile(getVersionFile(), version, "utf8");
systemLogger.info("Using bundled OPKSSH binary", {
operation: "opkssh_binary_bundled_used",
@@ -118,6 +192,8 @@ export class OPKSSHBinaryManager {
const binaryName = this.getBinaryName();
const binaryPath = path.join(getBinaryDir(), binaryName);
const temporaryPath = `${binaryPath}.download-${crypto.randomUUID()}`;
const { checksum } = getTrustedRelease(binaryName);
const response = await fetch(asset.browser_download_url);
if (!response.ok) {
@@ -128,15 +204,22 @@ export class OPKSSHBinaryManager {
throw new Error("Response body is null");
}
const fileStream = createWriteStream(binaryPath);
await pipeline(
response.body as unknown as NodeJS.ReadableStream,
fileStream,
);
await fs.chmod(binaryPath, 0o755);
try {
const fileStream = createWriteStream(temporaryPath, { mode: 0o600 });
await pipeline(
response.body as unknown as NodeJS.ReadableStream,
fileStream,
);
await verifyChecksum(temporaryPath, checksum);
await fs.chmod(temporaryPath, 0o755);
await replaceBinary(temporaryPath, binaryPath);
} catch (error) {
await fs.rm(temporaryPath, { force: true });
throw error;
}
await fs.writeFile(getVersionFile(), release.tag_name, "utf8");
await fs.writeFile(getChecksumFile(), checksum, "utf8");
systemLogger.info(
`OPKSSH binary downloaded successfully to ${binaryPath}`,
@@ -173,8 +256,7 @@ export class OPKSSHBinaryManager {
return true;
}
const release = await this.getLatestRelease();
const latestVersion = release.tag_name;
const latestVersion = getTrustedRelease(this.getBinaryName()).version;
if (localVersion !== latestVersion) {
return true;
@@ -191,7 +273,8 @@ export class OPKSSHBinaryManager {
}
private static async getLatestRelease(): Promise<GitHubRelease> {
const url = `https://api.github.com/repos/${OPKSSH_REPO}/releases/latest`;
const { version } = getTrustedRelease(this.getBinaryName());
const url = `https://api.github.com/repos/${OPKSSH_REPO}/releases/tags/${encodeURIComponent(version)}`;
const response = await fetch(url, {
headers: {
"User-Agent": "Termix",