Add linux check

This commit is contained in:
Jill Regan
2026-05-20 16:38:44 -04:00
parent 6ec01615e5
commit f3b8e180f2
4 changed files with 170 additions and 102 deletions
@@ -2,7 +2,6 @@ import os from "os";
import {
archMap,
CliInstaller,
cliUrlBuilder,
type SupportedPlatform,
} from "./cli-installer";
@@ -25,9 +24,7 @@ describe("LinuxInstaller", () => {
it("should call install with correct URL", async () => {
const installer = new LinuxInstaller(version);
const installMock = jest
.spyOn(CliInstaller.prototype, "install")
.mockResolvedValue();
const installMock = jest.spyOn(installer, "install").mockResolvedValue();
await installer.installCli();
@@ -1,9 +1,15 @@
import * as path from "path";
import * as core from "@actions/core";
import * as tc from "@actions/tool-cache";
import {
CliInstaller,
cliUrlBuilder,
type SupportedPlatform,
} from "./cli-installer";
import type { Installer } from "./installer";
import { verifyLinuxSignature } from "./signature";
export class LinuxInstaller extends CliInstaller implements Installer {
private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux
@@ -14,6 +20,22 @@ export class LinuxInstaller extends CliInstaller implements Installer {
public async installCli(): Promise<void> {
const urlBuilder = cliUrlBuilder[this.platform];
await super.install(urlBuilder(this.version, this.arch));
await this.install(urlBuilder(this.version, this.arch));
}
public override async install(url: string): Promise<void> {
console.info(`Downloading 1Password CLI from: ${url}`);
const downloadPath = await tc.downloadTool(url);
console.info("Installing 1Password CLI");
const extractedPath = await tc.extractZip(downloadPath);
core.info("Verifying 1Password CLI signature");
await verifyLinuxSignature(
path.join(extractedPath, "op"),
path.join(extractedPath, "op.sig"),
);
core.addPath(extractedPath);
core.info("1Password CLI installed");
}
}
@@ -1,130 +1,53 @@
import {
ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS,
APPLE_DEVELOPER_TEAM_ID,
ONEPASSWORD_GPG_KEY_FINGERPRINT,
ONEPASSWORD_GPG_KEY_URL,
verifyLinuxSignature,
verifyMacOsPackageSignature,
} from "./signature";
const FIRST_ALLOWED_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[0]!;
const SECOND_ALLOWED_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[1]!;
const fingerprintAsPkgutilLine = (hex: string): string => {
const bytes = hex.match(/.{2}/g);
if (!bytes) {
throw new Error("invalid hex");
}
const first = bytes.slice(0, 24).join(" ");
const second = bytes.slice(24).join(" ");
return ` ${first}\n ${second}`;
};
const VALID_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[0]!;
const buildPkgutilOutput = ({
teamId = APPLE_DEVELOPER_TEAM_ID,
signerFingerprint = FIRST_ALLOWED_FINGERPRINT,
includeChain = true,
includeSignerFingerprint = true,
signerFingerprint = VALID_FINGERPRINT,
}: {
teamId?: string;
signerFingerprint?: string;
includeChain?: boolean;
includeSignerFingerprint?: boolean;
} = {}): string => {
const signerFingerprintBlock = includeSignerFingerprint
? ` SHA256 Fingerprint:\n${fingerprintAsPkgutilLine(signerFingerprint)}\n`
: "";
const chain = includeChain
? ` Certificate Chain:
const bytes = signerFingerprint.match(/.{2}/g)!;
const fprLines = ` ${bytes.slice(0, 24).join(" ")}\n ${bytes.slice(24).join(" ")}`;
return `Package "op.pkg":
Certificate Chain:
1. Developer ID Installer: AgileBits Inc. (${teamId})
Expires: 2027-02-01 22:12:15 +0000
${signerFingerprintBlock} ------------------------------------------------------------------------
2. Developer ID Certification Authority
Expires: 2027-02-01 22:12:15 +0000
SHA256 Fingerprint:
7A FC 9D 01 A6 2F 03 A2 DE 96 37 93 6D 4A FE 68 09 0D 2D E1 8D 03 F2 9C
88 CF B0 B1 BA 63 58 7F
${fprLines}
------------------------------------------------------------------------
3. Apple Root CA
`
: "";
return `Package "op_apple_universal_v2.30.3.pkg":
Status: signed by a developer certificate issued by Apple for distribution
Signed with a trusted timestamp on: 2024-06-28 16:08:41 +0000
${chain}`;
2. Developer ID Certification Authority
`;
};
const pkgutilRunner = (output: string) =>
jest.fn<Promise<string>, [string]>().mockResolvedValue(output);
describe("verifyMacOsPackageSignature", () => {
it("passes for a pkg signed with the first allowlisted fingerprint", async () => {
const runner = jest.fn<Promise<string>, [string]>().mockResolvedValue(
buildPkgutilOutput({
signerFingerprint: FIRST_ALLOWED_FINGERPRINT,
}),
);
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).resolves.toBeUndefined();
expect(runner).toHaveBeenCalledWith("/tmp/op.pkg");
});
it("passes for a pkg signed with the second allowlisted fingerprint", async () => {
const runner = jest.fn<Promise<string>, [string]>().mockResolvedValue(
buildPkgutilOutput({
signerFingerprint: SECOND_ALLOWED_FINGERPRINT,
}),
);
it("passes for a pkg signed by AgileBits with an allowlisted cert", async () => {
const runner = pkgutilRunner(buildPkgutilOutput());
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).resolves.toBeUndefined();
});
it("normalizes whitespace and case when comparing fingerprints", async () => {
const lowered = FIRST_ALLOWED_FINGERPRINT.toLowerCase();
const runner = jest
.fn<Promise<string>, [string]>()
.mockResolvedValue(buildPkgutilOutput({ signerFingerprint: lowered }));
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).resolves.toBeUndefined();
});
it("throws if pkgutil exits non-zero", async () => {
const runner = jest
.fn<Promise<string>, [string]>()
.mockRejectedValue(new Error("not a package"));
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).rejects.toThrow(/pkgutil --check-signature errored.*not a package/);
});
it("throws if the output has no certificate chain", async () => {
const runner = jest
.fn<Promise<string>, [string]>()
.mockResolvedValue('Package "op.pkg":\n Status: no signature\n');
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).rejects.toThrow(/could not locate certificate chain/);
});
it("throws if the signer cert is not under the AgileBits team ID", async () => {
const runner = jest
.fn<Promise<string>, [string]>()
.mockResolvedValue(buildPkgutilOutput({ teamId: "ATTACKER123" }));
it("throws if the signer is not under the AgileBits team ID", async () => {
const runner = pkgutilRunner(buildPkgutilOutput({ teamId: "ATTACKER" }));
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).rejects.toThrow(/expected developer team ID 2BUA8C4S2C not found/);
});
it("throws if the signer cert fingerprint is missing from the output", async () => {
const runner = jest
.fn<Promise<string>, [string]>()
.mockResolvedValue(buildPkgutilOutput({ includeSignerFingerprint: false }));
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).rejects.toThrow(/could not parse signer cert SHA-256 fingerprint/);
});
it("throws if the signer cert fingerprint is not on the allowlist", async () => {
const runner = jest.fn<Promise<string>, [string]>().mockResolvedValue(
const runner = pkgutilRunner(
buildPkgutilOutput({
signerFingerprint:
"DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
@@ -135,3 +58,63 @@ describe("verifyMacOsPackageSignature", () => {
).rejects.toThrow(/not on the allowlist/);
});
});
describe("verifyLinuxSignature", () => {
const OP_PATH = "/tmp/op";
const SIG_PATH = "/tmp/op.sig";
const CORRECT_FPR = `fpr:::::::::${ONEPASSWORD_GPG_KEY_FINGERPRINT}:\n`;
const WRONG_FPR = `fpr:::::::::DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF:\n`;
const downloadKey = jest
.fn<Promise<string>, [string]>()
.mockResolvedValue("/tmp/key.asc");
beforeEach(() => downloadKey.mockClear());
const gpgRunner = (...responses: (string | Error)[]) => {
const runner = jest.fn<Promise<string>, [readonly string[]]>();
for (const r of responses) {
if (r instanceof Error) {
runner.mockRejectedValueOnce(r);
} else {
runner.mockResolvedValueOnce(r);
}
}
return runner;
};
const subcommandsCalled = (runner: ReturnType<typeof gpgRunner>) =>
runner.mock.calls.map(([args]: [readonly string[]]) =>
args.find(
(a) => a === "--import" || a === "--list-keys" || a === "--verify",
),
);
it("passes when the imported key matches and gpg --verify succeeds", async () => {
const runner = gpgRunner("", CORRECT_FPR, "");
await expect(
verifyLinuxSignature(OP_PATH, SIG_PATH, runner, downloadKey),
).resolves.toBeUndefined();
expect(downloadKey).toHaveBeenCalledWith(ONEPASSWORD_GPG_KEY_URL);
expect(subcommandsCalled(runner)).toEqual([
"--import",
"--list-keys",
"--verify",
]);
});
it("throws and skips --verify when the imported key fingerprint is wrong", async () => {
const runner = gpgRunner("", WRONG_FPR);
await expect(
verifyLinuxSignature(OP_PATH, SIG_PATH, runner, downloadKey),
).rejects.toThrow(/does not match expected/);
expect(subcommandsCalled(runner)).toEqual(["--import", "--list-keys"]);
});
it("throws when gpg --verify rejects the signature", async () => {
const runner = gpgRunner("", CORRECT_FPR, new Error("BAD signature"));
await expect(
verifyLinuxSignature(OP_PATH, SIG_PATH, runner, downloadKey),
).rejects.toThrow(/gpg --verify rejected.*BAD signature/);
});
});
@@ -1,6 +1,11 @@
import { execFile } from "child_process";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { promisify } from "util";
import * as tc from "@actions/tool-cache";
const execFileAsync = promisify(execFile);
// See https://www.1password.dev/cli/verify.
@@ -13,6 +18,13 @@ export const ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS = [
"141DD87B2B231211F1440849798007DF621DE6EB3DAB985BC964EE9704C4A1C1",
];
// 1Password's code-signing GPG key. Used to verify the detached `op.sig`
// inside the Linux release zip. See https://www.1password.dev/cli/verify.
export const ONEPASSWORD_GPG_KEY_FINGERPRINT =
"3FEF9748469ADBE15DA7CA80AC2D62742012EA22";
export const ONEPASSWORD_GPG_KEY_URL =
"https://downloads.1password.com/linux/keys/1password.asc";
const defaultPkgutilRunner = async (pkgPath: string): Promise<string> => {
const { stdout } = await execFileAsync("pkgutil", [
"--check-signature",
@@ -88,3 +100,57 @@ export const verifyMacOsPackageSignature = async (
);
}
};
const defaultGpgRunner = async (args: readonly string[]): Promise<string> => {
const { stdout } = await execFileAsync("gpg", args);
return stdout;
};
const defaultKeyDownloader = async (url: string): Promise<string> =>
tc.downloadTool(url);
// Throws unless `op` carries a valid GPG signature from the pinned 1Password
// key.
export const verifyLinuxSignature = async (
opPath: string,
sigPath: string,
runGpg: (args: readonly string[]) => Promise<string> = defaultGpgRunner,
downloadKey: (url: string) => Promise<string> = defaultKeyDownloader,
): Promise<void> => {
const gpgHome = fs.mkdtempSync(path.join(os.tmpdir(), "op-verify-"));
try {
const keyPath = await downloadKey(ONEPASSWORD_GPG_KEY_URL);
const baseArgs = ["--homedir", gpgHome, "--batch", "--no-tty"];
try {
await runGpg([...baseArgs, "--import", keyPath]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`1Password CLI signature verification failed: gpg --import failed: ${message}`,
);
}
const keyringListing = await runGpg([
...baseArgs,
"--list-keys",
"--with-colons",
]);
if (!keyringListing.includes(ONEPASSWORD_GPG_KEY_FINGERPRINT)) {
throw new Error(
`1Password CLI signature verification failed: downloaded GPG key does not match expected fingerprint ${ONEPASSWORD_GPG_KEY_FINGERPRINT}. The key endpoint may have been tampered with.\nKeyring contents:\n${keyringListing}`,
);
}
try {
await runGpg([...baseArgs, "--verify", sigPath, opPath]);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`1Password CLI signature verification failed: gpg --verify rejected the signature: ${message}`,
);
}
} finally {
fs.rmSync(gpgHome, { recursive: true, force: true });
}
};