diff --git a/src/op-cli-installer/github-action/cli-installer/linux.test.ts b/src/op-cli-installer/github-action/cli-installer/linux.test.ts index db8cd7a..f9877a7 100644 --- a/src/op-cli-installer/github-action/cli-installer/linux.test.ts +++ b/src/op-cli-installer/github-action/cli-installer/linux.test.ts @@ -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(); diff --git a/src/op-cli-installer/github-action/cli-installer/linux.ts b/src/op-cli-installer/github-action/cli-installer/linux.ts index 8d7c8ed..7e96121 100644 --- a/src/op-cli-installer/github-action/cli-installer/linux.ts +++ b/src/op-cli-installer/github-action/cli-installer/linux.ts @@ -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 { 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 { + 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"); } } diff --git a/src/op-cli-installer/github-action/cli-installer/signature.test.ts b/src/op-cli-installer/github-action/cli-installer/signature.test.ts index 1343b7d..7daa291 100644 --- a/src/op-cli-installer/github-action/cli-installer/signature.test.ts +++ b/src/op-cli-installer/github-action/cli-installer/signature.test.ts @@ -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, [string]>().mockResolvedValue(output); + describe("verifyMacOsPackageSignature", () => { - it("passes for a pkg signed with the first allowlisted fingerprint", async () => { - const runner = jest.fn, [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, [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, [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, [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, [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, [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, [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, [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, [string]>() + .mockResolvedValue("/tmp/key.asc"); + + beforeEach(() => downloadKey.mockClear()); + + const gpgRunner = (...responses: (string | Error)[]) => { + const runner = jest.fn, [readonly string[]]>(); + for (const r of responses) { + if (r instanceof Error) { + runner.mockRejectedValueOnce(r); + } else { + runner.mockResolvedValueOnce(r); + } + } + return runner; + }; + + const subcommandsCalled = (runner: ReturnType) => + 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/); + }); +}); diff --git a/src/op-cli-installer/github-action/cli-installer/signature.ts b/src/op-cli-installer/github-action/cli-installer/signature.ts index 19f1024..46110c9 100644 --- a/src/op-cli-installer/github-action/cli-installer/signature.ts +++ b/src/op-cli-installer/github-action/cli-installer/signature.ts @@ -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 => { const { stdout } = await execFileAsync("pkgutil", [ "--check-signature", @@ -88,3 +100,57 @@ export const verifyMacOsPackageSignature = async ( ); } }; + +const defaultGpgRunner = async (args: readonly string[]): Promise => { + const { stdout } = await execFileAsync("gpg", args); + return stdout; +}; + +const defaultKeyDownloader = async (url: string): Promise => + 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 = defaultGpgRunner, + downloadKey: (url: string) => Promise = defaultKeyDownloader, +): Promise => { + 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 }); + } +};