From d463472f19bfaa6c7a2d25685f146345e4a0359a Mon Sep 17 00:00:00 2001 From: Jill Regan Date: Thu, 21 May 2026 11:22:18 -0400 Subject: [PATCH] Add gpg fallback --- .../cli-installer/gpg-signature.test.ts | 61 +++++++++++++++++ .../cli-installer/gpg-signature.ts | 49 ++++++++++++++ .../cli-installer/linux-signature.test.ts | 65 ------------------- .../cli-installer/linux-signature.ts | 58 ----------------- .../github-action/cli-installer/linux.ts | 4 +- .../cli-installer/windows-signature.test.ts | 50 ++++++++++---- .../cli-installer/windows-signature.ts | 35 +++++++--- .../cli-installer/windows.test.ts | 6 +- .../github-action/cli-installer/windows.ts | 16 ++++- 9 files changed, 197 insertions(+), 147 deletions(-) create mode 100644 src/op-cli-installer/github-action/cli-installer/gpg-signature.test.ts create mode 100644 src/op-cli-installer/github-action/cli-installer/gpg-signature.ts delete mode 100644 src/op-cli-installer/github-action/cli-installer/linux-signature.test.ts delete mode 100644 src/op-cli-installer/github-action/cli-installer/linux-signature.ts diff --git a/src/op-cli-installer/github-action/cli-installer/gpg-signature.test.ts b/src/op-cli-installer/github-action/cli-installer/gpg-signature.test.ts new file mode 100644 index 0000000..c8e4bbf --- /dev/null +++ b/src/op-cli-installer/github-action/cli-installer/gpg-signature.test.ts @@ -0,0 +1,61 @@ +import { + ONEPASSWORD_GPG_KEY_FINGERPRINT, + ONEPASSWORD_GPG_KEYSERVER, + verifyGpgSignature, +} from "./gpg-signature"; + +describe("verifyGpgSignature", () => { + const OP_PATH = "/tmp/op"; + const SIG_PATH = `${OP_PATH}.sig`; + + 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 === "--recv-keys" || a === "--verify"), + ); + + it("fetches the pinned key by fingerprint and verifies the signature", async () => { + const runner = gpgRunner("", ""); + await expect( + verifyGpgSignature(OP_PATH, SIG_PATH, runner), + ).resolves.toBeUndefined(); + + expect(subcommandsCalled(runner)).toEqual(["--recv-keys", "--verify"]); + + const recvKeysArgs = runner.mock.calls[0]![0]; + expect(recvKeysArgs).toEqual( + expect.arrayContaining([ + "--keyserver", + ONEPASSWORD_GPG_KEYSERVER, + "--recv-keys", + ONEPASSWORD_GPG_KEY_FINGERPRINT, + ]), + ); + }); + + it("throws if recv-keys fails (e.g., wrong fingerprint or keyserver unreachable)", async () => { + const runner = gpgRunner(new Error("No data")); + await expect(verifyGpgSignature(OP_PATH, SIG_PATH, runner)).rejects.toThrow( + /No data/, + ); + expect(subcommandsCalled(runner)).toEqual(["--recv-keys"]); + }); + + it("throws if gpg --verify rejects the signature", async () => { + const runner = gpgRunner("", new Error("BAD signature")); + await expect(verifyGpgSignature(OP_PATH, SIG_PATH, runner)).rejects.toThrow( + /BAD signature/, + ); + }); +}); diff --git a/src/op-cli-installer/github-action/cli-installer/gpg-signature.ts b/src/op-cli-installer/github-action/cli-installer/gpg-signature.ts new file mode 100644 index 0000000..f1fb5be --- /dev/null +++ b/src/op-cli-installer/github-action/cli-installer/gpg-signature.ts @@ -0,0 +1,49 @@ +import { execFile } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +// 1Password's code-signing GPG key fingerprint. Used to verify the detached +// `op.sig` / `op.exe.sig` inside the Linux and Windows release zips. +// See https://www.1password.dev/cli/verify. +export const ONEPASSWORD_GPG_KEY_FINGERPRINT = + "3FEF9748469ADBE15DA7CA80AC2D62742012EA22"; +export const ONEPASSWORD_GPG_KEYSERVER = "keyserver.ubuntu.com"; + +const defaultGpgRunner = async (args: readonly string[]): Promise => { + const { stdout } = await execFileAsync("gpg", args); + return stdout; +}; + +// Throws unless the binary at opPath carries a valid GPG signature (at +// sigPath) from the pinned 1Password key. +// +// gpg --keyserver keyserver.ubuntu.com --recv-keys +// gpg --verify +export const verifyGpgSignature = async ( + opPath: string, + sigPath: string, + runGpg: (args: readonly string[]) => Promise = defaultGpgRunner, +): Promise => { + const gpgHome = fs.mkdtempSync(path.join(os.tmpdir(), "op-verify-")); + try { + const baseArgs = ["--homedir", gpgHome, "--batch", "--no-tty"]; + + // Fetch the 1Password public key by fingerprint. gpg only accepts a + // key whose fingerprint matches the requested value. + await runGpg([ + ...baseArgs, + "--keyserver", + ONEPASSWORD_GPG_KEYSERVER, + "--recv-keys", + ONEPASSWORD_GPG_KEY_FINGERPRINT, + ]); + + await runGpg([...baseArgs, "--verify", sigPath, opPath]); + } finally { + fs.rmSync(gpgHome, { recursive: true, force: true }); + } +}; diff --git a/src/op-cli-installer/github-action/cli-installer/linux-signature.test.ts b/src/op-cli-installer/github-action/cli-installer/linux-signature.test.ts deleted file mode 100644 index b0d2d2d..0000000 --- a/src/op-cli-installer/github-action/cli-installer/linux-signature.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - ONEPASSWORD_GPG_KEY_FINGERPRINT, - ONEPASSWORD_GPG_KEY_URL, - verifyLinuxSignature, -} from "./linux-signature"; - -describe("verifyLinuxSignature", () => { - const OP_PATH = "/tmp/op"; - const SIG_PATH = `${OP_PATH}.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(/BAD signature/); - }); -}); diff --git a/src/op-cli-installer/github-action/cli-installer/linux-signature.ts b/src/op-cli-installer/github-action/cli-installer/linux-signature.ts deleted file mode 100644 index ffd8159..0000000 --- a/src/op-cli-installer/github-action/cli-installer/linux-signature.ts +++ /dev/null @@ -1,58 +0,0 @@ -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); - -// 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 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 the binary at opPath carries a valid GPG signature (at -// sigPath) 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 { - // Fetch the 1Password public key so gpg can import it. - const keyPath = await downloadKey(ONEPASSWORD_GPG_KEY_URL); - const baseArgs = ["--homedir", gpgHome, "--batch", "--no-tty"]; - - await runGpg([...baseArgs, "--import", keyPath]); - - // Confirm gpg imported the pinned key. - 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}`, - ); - } - - await runGpg([...baseArgs, "--verify", sigPath, opPath]); - } finally { - fs.rmSync(gpgHome, { recursive: true, force: true }); - } -}; 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 748d23d..2f39512 100644 --- a/src/op-cli-installer/github-action/cli-installer/linux.ts +++ b/src/op-cli-installer/github-action/cli-installer/linux.ts @@ -9,7 +9,7 @@ import { type SupportedPlatform, } from "./cli-installer"; import type { Installer } from "./installer"; -import { verifyLinuxSignature } from "./linux-signature"; +import { verifyGpgSignature } from "./gpg-signature"; export class LinuxInstaller extends CliInstaller implements Installer { private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux @@ -30,7 +30,7 @@ export class LinuxInstaller extends CliInstaller implements Installer { const extractedPath = await tc.extractZip(downloadPath); core.info("Verifying 1Password CLI signature"); - await verifyLinuxSignature( + await verifyGpgSignature( path.join(extractedPath, "op"), path.join(extractedPath, "op.sig"), ); diff --git a/src/op-cli-installer/github-action/cli-installer/windows-signature.test.ts b/src/op-cli-installer/github-action/cli-installer/windows-signature.test.ts index 800a41e..2d41b5b 100644 --- a/src/op-cli-installer/github-action/cli-installer/windows-signature.test.ts +++ b/src/op-cli-installer/github-action/cli-installer/windows-signature.test.ts @@ -1,11 +1,12 @@ import { - verifyWindowsBinarySignature, + isAzureSignedEra, + verifyAuthenticodeSignature, WINDOWS_ISSUER_CN_PREFIX, WINDOWS_PUBLISHER_EKU, WINDOWS_SIGNER_SUBJECT_CN, } from "./windows-signature"; -describe("verifyWindowsBinarySignature", () => { +describe("verifyAuthenticodeSignature", () => { const OP_EXE = "C:\\op\\op.exe"; const buildAuthenticodeOutput = ({ @@ -33,20 +34,18 @@ describe("verifyWindowsBinarySignature", () => { const powershellRunner = (output: string) => jest.fn, [string]>().mockResolvedValue(output); - it("passes for op.exe signed by AgileBits with the expected EKU", async () => { + it("passes for an Azure-signed op.exe", async () => { const runner = powershellRunner(buildAuthenticodeOutput()); await expect( - verifyWindowsBinarySignature(OP_EXE, runner), + verifyAuthenticodeSignature(OP_EXE, runner), ).resolves.toBeUndefined(); }); it("throws if the signer Subject is not AgileBits", async () => { const runner = powershellRunner( - buildAuthenticodeOutput({ - subject: "CN=Attacker, O=Attacker, C=US", - }), + buildAuthenticodeOutput({ subject: "CN=Attacker, O=Attacker, C=US" }), ); - await expect(verifyWindowsBinarySignature(OP_EXE, runner)).rejects.toThrow( + await expect(verifyAuthenticodeSignature(OP_EXE, runner)).rejects.toThrow( /does not contain CN=Agilebits/, ); }); @@ -54,10 +53,11 @@ describe("verifyWindowsBinarySignature", () => { it("throws if the Issuer is not the expected Microsoft CA", async () => { const runner = powershellRunner( buildAuthenticodeOutput({ - issuer: "CN=Some Other CA, O=Someone, C=US", + issuer: + "CN=Sectigo Public Code Signing CA R36, O=Sectigo Limited, C=GB", }), ); - await expect(verifyWindowsBinarySignature(OP_EXE, runner)).rejects.toThrow( + await expect(verifyAuthenticodeSignature(OP_EXE, runner)).rejects.toThrow( /does not contain CN=Microsoft ID Verified/, ); }); @@ -68,8 +68,36 @@ describe("verifyWindowsBinarySignature", () => { ekus: ["1.3.6.1.4.1.311.97.1.0", "1.3.6.1.5.5.7.3.3"], }), ); - await expect(verifyWindowsBinarySignature(OP_EXE, runner)).rejects.toThrow( + await expect(verifyAuthenticodeSignature(OP_EXE, runner)).rejects.toThrow( /expected publisher EKU.*not found/, ); }); }); + +describe("isAzureSignedEra", () => { + it("returns true for the cutoff version (2.31.0)", () => { + expect(isAzureSignedEra("2.31.0")).toBe(true); + }); + + it("returns true for the first Azure beta (2.31.0-beta.01)", () => { + expect(isAzureSignedEra("2.31.0-beta.01")).toBe(true); + }); + + it("returns true for versions newer than the cutoff", () => { + expect(isAzureSignedEra("2.34.0")).toBe(true); + expect(isAzureSignedEra("v3.0.0")).toBe(true); + }); + + it("returns false for the last Sectigo version (2.30.3)", () => { + expect(isAzureSignedEra("2.30.3")).toBe(false); + }); + + it("returns false for older versions", () => { + expect(isAzureSignedEra("2.20.0")).toBe(false); + expect(isAzureSignedEra("v2.0.0")).toBe(false); + }); + + it("returns true for unrecognized version formats (fail closed)", () => { + expect(isAzureSignedEra("not-a-version")).toBe(true); + }); +}); diff --git a/src/op-cli-installer/github-action/cli-installer/windows-signature.ts b/src/op-cli-installer/github-action/cli-installer/windows-signature.ts index f275d2c..4083361 100644 --- a/src/op-cli-installer/github-action/cli-installer/windows-signature.ts +++ b/src/op-cli-installer/github-action/cli-installer/windows-signature.ts @@ -1,6 +1,8 @@ import { execFile } from "child_process"; import { promisify } from "util"; +import semver from "semver"; + const execFileAsync = promisify(execFile); // Identifying fields of 1Password's Authenticode signing cert for op.exe. @@ -10,6 +12,22 @@ export const WINDOWS_ISSUER_CN_PREFIX = "Microsoft ID Verified CS AOC CA"; export const WINDOWS_PUBLISHER_EKU = "1.3.6.1.4.1.311.97.661420558.769123285.207353056.500447802"; +// First op.exe version signed via Azure Trusted Signing (v2.31.0-beta.01, +// April 2025). Earlier versions are Sectigo-signed and verified via GPG. +export const AZURE_SIGNING_CUTOFF = "2.31.0-0"; + +export const isAzureSignedEra = (cliVersion: string): boolean => { + try { + const normalized = cliVersion + .replace(/^v/, "") + .replace(/-beta\.0*(\d+)/, "-beta.$1"); + return semver.gte(normalized, AZURE_SIGNING_CUTOFF); + } catch { + // Unrecognized version format — default to modern (strict) verification. + return true; + } +}; + const defaultPowerShellRunner = async (script: string): Promise => { const { stdout } = await execFileAsync("powershell.exe", [ "-NoProfile", @@ -20,9 +38,10 @@ const defaultPowerShellRunner = async (script: string): Promise => { return stdout; }; -// Throws unless op.exe at opExePath carries a valid Authenticode signature -// from 1Password (AgileBits) issued by Microsoft, with the publisher EKU. -export const verifyWindowsBinarySignature = async ( +// Strict Authenticode check against 1Password's Azure Trusted Signing cert. +// Throws unless Status is Valid, signer is AgileBits, issuer is a Microsoft +// CS AOC CA, and the publisher EKU is present. +export const verifyAuthenticodeSignature = async ( opExePath: string, runPowerShell: (script: string) => Promise = defaultPowerShellRunner, ): Promise => { @@ -51,15 +70,15 @@ export const verifyWindowsBinarySignature = async ( const status = fieldValue("Status="); if (status !== "Valid") { throw new Error( - `1Password CLI signature verification failed: Authenticode status is ${status ?? "unknown"}, expected Valid.\nGet-AuthenticodeSignature output:\n${output}`, + `Authenticode status is ${status ?? "unknown"}, expected Valid.\nGet-AuthenticodeSignature output:\n${output}`, ); } // Confirm the signer is AgileBits, not some other publisher. const subject = fieldValue("Subject=") ?? ""; - if (!subject.includes(`CN=${WINDOWS_SIGNER_SUBJECT_CN},`)) { + if (!subject.includes(`CN=${WINDOWS_SIGNER_SUBJECT_CN}`)) { throw new Error( - `1Password CLI signature verification failed: signer Subject (${subject}) does not contain CN=${WINDOWS_SIGNER_SUBJECT_CN}.`, + `signer Subject (${subject}) does not contain CN=${WINDOWS_SIGNER_SUBJECT_CN}.`, ); } @@ -67,7 +86,7 @@ export const verifyWindowsBinarySignature = async ( const issuer = fieldValue("Issuer=") ?? ""; if (!issuer.includes(`CN=${WINDOWS_ISSUER_CN_PREFIX}`)) { throw new Error( - `1Password CLI signature verification failed: issuer (${issuer}) does not contain CN=${WINDOWS_ISSUER_CN_PREFIX}.`, + `issuer (${issuer}) does not contain CN=${WINDOWS_ISSUER_CN_PREFIX}.`, ); } @@ -76,7 +95,7 @@ export const verifyWindowsBinarySignature = async ( .map((l) => l.slice("EKU=".length)); if (!ekus.includes(WINDOWS_PUBLISHER_EKU)) { throw new Error( - `1Password CLI signature verification failed: expected publisher EKU ${WINDOWS_PUBLISHER_EKU} not found in (${ekus.join(", ") || "none"}).`, + `expected publisher EKU ${WINDOWS_PUBLISHER_EKU} not found in (${ekus.join(", ") || "none"}).`, ); } }; diff --git a/src/op-cli-installer/github-action/cli-installer/windows.test.ts b/src/op-cli-installer/github-action/cli-installer/windows.test.ts index 4474b5c..b23a38c 100644 --- a/src/op-cli-installer/github-action/cli-installer/windows.test.ts +++ b/src/op-cli-installer/github-action/cli-installer/windows.test.ts @@ -13,7 +13,11 @@ import { WindowsInstaller } from "./windows"; jest.mock("fs"); jest.mock("./windows-signature", () => ({ - verifyWindowsBinarySignature: jest.fn().mockResolvedValue(undefined), + verifyAuthenticodeSignature: jest.fn().mockResolvedValue(undefined), + isAzureSignedEra: jest.fn().mockReturnValue(true), +})); +jest.mock("./gpg-signature", () => ({ + verifyGpgSignature: jest.fn().mockResolvedValue(undefined), })); afterEach(() => { diff --git a/src/op-cli-installer/github-action/cli-installer/windows.ts b/src/op-cli-installer/github-action/cli-installer/windows.ts index d840a75..3f6530d 100644 --- a/src/op-cli-installer/github-action/cli-installer/windows.ts +++ b/src/op-cli-installer/github-action/cli-installer/windows.ts @@ -9,8 +9,12 @@ import { cliUrlBuilder, type SupportedPlatform, } from "./cli-installer"; +import { verifyGpgSignature } from "./gpg-signature"; import type { Installer } from "./installer"; -import { verifyWindowsBinarySignature } from "./windows-signature"; +import { + isAzureSignedEra, + verifyAuthenticodeSignature, +} from "./windows-signature"; export class WindowsInstaller extends CliInstaller implements Installer { private readonly platform: SupportedPlatform = "win32"; // Node.js platform identifier for Windows @@ -35,7 +39,15 @@ export class WindowsInstaller extends CliInstaller implements Installer { const extractedPath = await tc.extractZip(zipPath); core.info("Verifying 1Password CLI signature"); - await verifyWindowsBinarySignature(path.join(extractedPath, "op.exe")); + const opExePath = path.join(extractedPath, "op.exe"); + if (isAzureSignedEra(this.version)) { + await verifyAuthenticodeSignature(opExePath); + } else { + await verifyGpgSignature( + opExePath, + path.join(extractedPath, "op.exe.sig"), + ); + } core.info("1Password CLI signature verified"); core.addPath(extractedPath);