mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: verify OPKSSH binary integrity (#1318)
This commit is contained in:
+5
-2
@@ -41,17 +41,20 @@ RUN npm run build:backend
|
|||||||
FROM node:24-slim AS opkssh-downloader
|
FROM node:24-slim AS opkssh-downloader
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG OPKSSH_VERSION=v0.16.0
|
ARG OPKSSH_VERSION=v0.16.0
|
||||||
|
ARG OPKSSH_SHA256_AMD64=c018c3e7baf98612b923e742dd87be38650bf61e3b755fb2bc90de177568b1bf
|
||||||
|
ARG OPKSSH_SHA256_ARM64=9dd10c2b6ce99cde18e52c054877ca014134b291fd82afe71741c68db4f83d44
|
||||||
WORKDIR /opkssh
|
WORKDIR /opkssh
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN case "$TARGETARCH" in \
|
RUN case "$TARGETARCH" in \
|
||||||
amd64) OPKSSH_ARCH=amd64 ;; \
|
amd64) OPKSSH_ARCH=amd64; OPKSSH_SHA256="$OPKSSH_SHA256_AMD64" ;; \
|
||||||
arm64) OPKSSH_ARCH=arm64 ;; \
|
arm64) OPKSSH_ARCH=arm64; OPKSSH_SHA256="$OPKSSH_SHA256_ARM64" ;; \
|
||||||
*) echo "Unsupported architecture: $TARGETARCH" && exit 1 ;; \
|
*) echo "Unsupported architecture: $TARGETARCH" && exit 1 ;; \
|
||||||
esac && \
|
esac && \
|
||||||
curl -fSL -o "opkssh-linux-${OPKSSH_ARCH}" \
|
curl -fSL -o "opkssh-linux-${OPKSSH_ARCH}" \
|
||||||
"https://github.com/openpubkey/opkssh/releases/download/${OPKSSH_VERSION}/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}" && \
|
chmod 755 "opkssh-linux-${OPKSSH_ARCH}" && \
|
||||||
echo -n "$OPKSSH_VERSION" > version.txt
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,26 @@
|
|||||||
import { getErrorMessage } from "./error-message.js";
|
import { getErrorMessage } from "./error-message.js";
|
||||||
import { createWriteStream, promises as fs } from "fs";
|
import { createWriteStream, promises as fs } from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { pipeline } from "stream/promises";
|
import { pipeline } from "stream/promises";
|
||||||
import { systemLogger } from "./logger.js";
|
import { systemLogger } from "./logger.js";
|
||||||
|
|
||||||
const OPKSSH_REPO = "openpubkey/opkssh";
|
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 {
|
function getBinaryDir(): string {
|
||||||
const dataDir =
|
const dataDir =
|
||||||
@@ -16,6 +32,65 @@ function getVersionFile(): string {
|
|||||||
return path.join(getBinaryDir(), "version.txt");
|
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 {
|
function getBundledDir(): string {
|
||||||
return (
|
return (
|
||||||
process.env.OPKSSH_BUNDLED_DIR || path.join(process.cwd(), "opkssh-bundled")
|
process.env.OPKSSH_BUNDLED_DIR || path.join(process.cwd(), "opkssh-bundled")
|
||||||
@@ -45,6 +120,10 @@ export class OPKSSHBinaryManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(expectedPath);
|
await fs.access(expectedPath);
|
||||||
|
const storedChecksum = (await fs.readFile(getChecksumFile(), "utf8"))
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
await verifyChecksum(expectedPath, storedChecksum);
|
||||||
const needsUpdate = await this.checkForUpdate();
|
const needsUpdate = await this.checkForUpdate();
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
systemLogger.info("Newer OPKSSH version available, updating...", {
|
systemLogger.info("Newer OPKSSH version available, updating...", {
|
||||||
@@ -76,22 +155,17 @@ export class OPKSSHBinaryManager {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const binaryName = this.getBinaryName();
|
const binaryName = this.getBinaryName();
|
||||||
const bundledPath = path.join(getBundledDir(), binaryName);
|
const bundledPath = path.join(getBundledDir(), binaryName);
|
||||||
const bundledVersionFile = path.join(getBundledDir(), "version.txt");
|
const { version, checksum } = getTrustedRelease(binaryName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(bundledPath);
|
await fs.access(bundledPath);
|
||||||
|
await verifyChecksum(bundledPath, checksum);
|
||||||
await fs.mkdir(getBinaryDir(), { recursive: true });
|
await fs.mkdir(getBinaryDir(), { recursive: true });
|
||||||
await fs.copyFile(bundledPath, expectedPath);
|
await fs.copyFile(bundledPath, expectedPath);
|
||||||
await fs.chmod(expectedPath, 0o755);
|
await fs.chmod(expectedPath, 0o755);
|
||||||
|
await fs.writeFile(getChecksumFile(), checksum, "utf8");
|
||||||
|
|
||||||
try {
|
await fs.writeFile(getVersionFile(), version, "utf8");
|
||||||
const bundledVersion = (
|
|
||||||
await fs.readFile(bundledVersionFile, "utf8")
|
|
||||||
).trim();
|
|
||||||
await fs.writeFile(getVersionFile(), bundledVersion, "utf8");
|
|
||||||
} catch {
|
|
||||||
// Bundled version file is optional
|
|
||||||
}
|
|
||||||
|
|
||||||
systemLogger.info("Using bundled OPKSSH binary", {
|
systemLogger.info("Using bundled OPKSSH binary", {
|
||||||
operation: "opkssh_binary_bundled_used",
|
operation: "opkssh_binary_bundled_used",
|
||||||
@@ -118,6 +192,8 @@ export class OPKSSHBinaryManager {
|
|||||||
|
|
||||||
const binaryName = this.getBinaryName();
|
const binaryName = this.getBinaryName();
|
||||||
const binaryPath = path.join(getBinaryDir(), binaryName);
|
const binaryPath = path.join(getBinaryDir(), binaryName);
|
||||||
|
const temporaryPath = `${binaryPath}.download-${crypto.randomUUID()}`;
|
||||||
|
const { checksum } = getTrustedRelease(binaryName);
|
||||||
|
|
||||||
const response = await fetch(asset.browser_download_url);
|
const response = await fetch(asset.browser_download_url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -128,15 +204,22 @@ export class OPKSSHBinaryManager {
|
|||||||
throw new Error("Response body is null");
|
throw new Error("Response body is null");
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileStream = createWriteStream(binaryPath);
|
try {
|
||||||
|
const fileStream = createWriteStream(temporaryPath, { mode: 0o600 });
|
||||||
await pipeline(
|
await pipeline(
|
||||||
response.body as unknown as NodeJS.ReadableStream,
|
response.body as unknown as NodeJS.ReadableStream,
|
||||||
fileStream,
|
fileStream,
|
||||||
);
|
);
|
||||||
|
await verifyChecksum(temporaryPath, checksum);
|
||||||
await fs.chmod(binaryPath, 0o755);
|
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(getVersionFile(), release.tag_name, "utf8");
|
||||||
|
await fs.writeFile(getChecksumFile(), checksum, "utf8");
|
||||||
|
|
||||||
systemLogger.info(
|
systemLogger.info(
|
||||||
`OPKSSH binary downloaded successfully to ${binaryPath}`,
|
`OPKSSH binary downloaded successfully to ${binaryPath}`,
|
||||||
@@ -173,8 +256,7 @@ export class OPKSSHBinaryManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const release = await this.getLatestRelease();
|
const latestVersion = getTrustedRelease(this.getBinaryName()).version;
|
||||||
const latestVersion = release.tag_name;
|
|
||||||
|
|
||||||
if (localVersion !== latestVersion) {
|
if (localVersion !== latestVersion) {
|
||||||
return true;
|
return true;
|
||||||
@@ -191,7 +273,8 @@ export class OPKSSHBinaryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async getLatestRelease(): Promise<GitHubRelease> {
|
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, {
|
const response = await fetch(url, {
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": "Termix",
|
"User-Agent": "Termix",
|
||||||
|
|||||||
Reference in New Issue
Block a user