diff --git a/src/op-cli-installer/github-action/cli-installer/gpg-signature.test.ts b/src/op-cli-installer/github-action/cli-installer/linux-signature.test.ts similarity index 80% rename from src/op-cli-installer/github-action/cli-installer/gpg-signature.test.ts rename to src/op-cli-installer/github-action/cli-installer/linux-signature.test.ts index c8e4bbf..fffb85a 100644 --- a/src/op-cli-installer/github-action/cli-installer/gpg-signature.test.ts +++ b/src/op-cli-installer/github-action/cli-installer/linux-signature.test.ts @@ -1,10 +1,10 @@ import { ONEPASSWORD_GPG_KEY_FINGERPRINT, ONEPASSWORD_GPG_KEYSERVER, - verifyGpgSignature, -} from "./gpg-signature"; + verifyLinuxSignature, +} from "./linux-signature"; -describe("verifyGpgSignature", () => { +describe("verifyLinuxSignature", () => { const OP_PATH = "/tmp/op"; const SIG_PATH = `${OP_PATH}.sig`; @@ -28,7 +28,7 @@ describe("verifyGpgSignature", () => { it("fetches the pinned key by fingerprint and verifies the signature", async () => { const runner = gpgRunner("", ""); await expect( - verifyGpgSignature(OP_PATH, SIG_PATH, runner), + verifyLinuxSignature(OP_PATH, SIG_PATH, runner), ).resolves.toBeUndefined(); expect(subcommandsCalled(runner)).toEqual(["--recv-keys", "--verify"]); @@ -46,16 +46,16 @@ describe("verifyGpgSignature", () => { 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/, - ); + await expect( + verifyLinuxSignature(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/, - ); + await expect( + verifyLinuxSignature(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/linux-signature.ts similarity index 93% rename from src/op-cli-installer/github-action/cli-installer/gpg-signature.ts rename to src/op-cli-installer/github-action/cli-installer/linux-signature.ts index f1fb5be..54bac82 100644 --- a/src/op-cli-installer/github-action/cli-installer/gpg-signature.ts +++ b/src/op-cli-installer/github-action/cli-installer/linux-signature.ts @@ -7,7 +7,7 @@ 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. +// `op.sig` inside the Linux release zip. // See https://www.1password.dev/cli/verify. export const ONEPASSWORD_GPG_KEY_FINGERPRINT = "3FEF9748469ADBE15DA7CA80AC2D62742012EA22"; @@ -23,7 +23,7 @@ const defaultGpgRunner = async (args: readonly string[]): Promise => { // // gpg --keyserver keyserver.ubuntu.com --recv-keys // gpg --verify -export const verifyGpgSignature = async ( +export const verifyLinuxSignature = async ( opPath: string, sigPath: string, runGpg: (args: readonly string[]) => Promise = defaultGpgRunner, 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 2f39512..748d23d 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 { verifyGpgSignature } from "./gpg-signature"; +import { verifyLinuxSignature } from "./linux-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 verifyGpgSignature( + await verifyLinuxSignature( 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 cc376f4..143c516 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,8 +1,5 @@ import { - isAzureSignedEra, verifyAuthenticodeSignature, - WINDOWS_ISSUER_CN_PREFIX, - WINDOWS_PUBLISHER_EKU, WINDOWS_SIGNER_SUBJECT_CN, } from "./windows-signature"; @@ -11,37 +8,30 @@ describe("verifyAuthenticodeSignature", () => { const buildAuthenticodeOutput = ({ status = "Valid", - subject = `CN=${WINDOWS_SIGNER_SUBJECT_CN}, O=Agilebits, L=Toronto, S=Ontario, C=CA`, - issuer = `CN=${WINDOWS_ISSUER_CN_PREFIX} 03, O=Microsoft Corporation, C=US`, - ekus = [ - "1.3.6.1.4.1.311.97.1.0", - "1.3.6.1.5.5.7.3.3", - WINDOWS_PUBLISHER_EKU, - ], - }: { - status?: string; - subject?: string; - issuer?: string; - ekus?: string[]; - } = {}): string => - [ - `Status=${status}`, - `Subject=${subject}`, - `Issuer=${issuer}`, - ...ekus.map((e) => `EKU=${e}`), - ].join("\n") + "\n"; + subject = `CN=${WINDOWS_SIGNER_SUBJECT_CN}, O=Agilebits, C=CA`, + }: { status?: string; subject?: string } = {}): string => + [`Status=${status}`, `Subject=${subject}`].join("\n") + "\n"; const powershellRunner = (output: string) => jest.fn, [string]>().mockResolvedValue(output); - it("passes for an Azure-signed op.exe", async () => { + it("passes for a valid AgileBits-signed binary", async () => { const runner = powershellRunner(buildAuthenticodeOutput()); await expect( verifyAuthenticodeSignature(OP_EXE, runner), ).resolves.toBeUndefined(); }); - it("throws if the signer Subject is not AgileBits", async () => { + it("throws if Status is not Valid (unsigned or tampered)", async () => { + const runner = powershellRunner( + buildAuthenticodeOutput({ status: "HashMismatch" }), + ); + await expect(verifyAuthenticodeSignature(OP_EXE, runner)).rejects.toThrow( + /Authenticode status is HashMismatch/, + ); + }); + + it("throws if the signer is not AgileBits", async () => { const runner = powershellRunner( buildAuthenticodeOutput({ subject: "CN=Attacker, O=Attacker, C=US" }), ); @@ -49,77 +39,4 @@ describe("verifyAuthenticodeSignature", () => { /does not contain CN=Agilebits/, ); }); - - it("throws if the Issuer is not the expected Microsoft CA", async () => { - const runner = powershellRunner( - buildAuthenticodeOutput({ - issuer: - "CN=Sectigo Public Code Signing CA R36, O=Sectigo Limited, C=GB", - }), - ); - await expect(verifyAuthenticodeSignature(OP_EXE, runner)).rejects.toThrow( - /does not contain CN=Microsoft ID Verified/, - ); - }); - - it("throws if the publisher EKU is missing", async () => { - const runner = powershellRunner( - buildAuthenticodeOutput({ - ekus: ["1.3.6.1.4.1.311.97.1.0", "1.3.6.1.5.5.7.3.3"], - }), - ); - await expect(verifyAuthenticodeSignature(OP_EXE, runner)).rejects.toThrow( - /expected publisher EKU.*not found/, - ); - }); - - it("loose mode passes for a Sectigo-issued cert (no Microsoft CS AOC CA issuer)", async () => { - const runner = powershellRunner( - buildAuthenticodeOutput({ - issuer: - "CN=Sectigo Public Code Signing CA R36, O=Sectigo Limited, C=GB", - ekus: ["1.3.6.1.5.5.7.3.3"], - }), - ); - await expect( - verifyAuthenticodeSignature(OP_EXE, runner, false), - ).resolves.toBeUndefined(); - }); - - it("loose mode still rejects an unsigned or wrong-publisher binary", async () => { - const runner = powershellRunner( - buildAuthenticodeOutput({ subject: "CN=Attacker, O=Attacker, C=US" }), - ); - await expect( - verifyAuthenticodeSignature(OP_EXE, runner, false), - ).rejects.toThrow(/does not contain CN=Agilebits/); - }); -}); - -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 b170746..f83b8d5 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,32 +1,11 @@ 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. +// Identifying field of 1Password's Authenticode signing cert for op.exe. // See https://www.1password.dev/cli/verify. export const WINDOWS_SIGNER_SUBJECT_CN = "Agilebits"; -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", [ @@ -38,33 +17,21 @@ const defaultPowerShellRunner = async (script: string): Promise => { return stdout; }; -// Authenticode check against 1Password's signing cert. -// -// Strict mode (default, for Azure Trusted Signing era): throws unless Status -// is Valid, signer is AgileBits, issuer is a Microsoft CS AOC CA, and the -// publisher EKU is present. -// -// Loose mode (for Sectigo era, pre-v2.31.0): only checks Status is Valid and -// signer is AgileBits. The Sectigo-issued cert has no Microsoft issuer and no -// publisher EKU, so the strict checks don't apply. +// Verifies op.exe's Authenticode signature against 1Password's signing cert. +// Throws unless the signature is cryptographically valid and the signer is +// AgileBits. export const verifyAuthenticodeSignature = async ( opExePath: string, runPowerShell: (script: string) => Promise = defaultPowerShellRunner, - strict = true, ): Promise => { - // Read the four Authenticode fields we validate below. const escapedPath = opExePath.replace(/'/g, "''"); const script = [ `$sig = Get-AuthenticodeSignature -FilePath '${escapedPath}'`, `"Status=$($sig.Status)"`, `"Subject=$($sig.SignerCertificate.Subject)"`, - `"Issuer=$($sig.SignerCertificate.Issuer)"`, - `$sig.SignerCertificate.EnhancedKeyUsageList | %{ "EKU=$($_.ObjectId)" }`, ].join("; "); const output = await runPowerShell(script); - // TEMPORARY DEBUG — remove before merging. - console.info(`Authenticode raw output:\n${output}`); const outputLines = output.split("\n").map((l) => l.trim()); const fieldValue = (prefix: string): string | undefined => { @@ -90,27 +57,4 @@ export const verifyAuthenticodeSignature = async ( `signer Subject (${subject}) does not contain CN=${WINDOWS_SIGNER_SUBJECT_CN}.`, ); } - - // Loose mode (Sectigo era) stops here. Sectigo certs aren't issued by a - // Microsoft CS AOC CA, so the strict issuer check below doesn't apply. - if (!strict) { - return; - } - - // Confirm the cert was issued by Microsoft's expected code signing CA. - const issuer = fieldValue("Issuer=") ?? ""; - if (!issuer.includes(`CN=${WINDOWS_ISSUER_CN_PREFIX}`)) { - throw new Error( - `issuer (${issuer}) does not contain CN=${WINDOWS_ISSUER_CN_PREFIX}.`, - ); - } - - const ekus = outputLines - .filter((l) => l.startsWith("EKU=")) - .map((l) => l.slice("EKU=".length)); - if (!ekus.includes(WINDOWS_PUBLISHER_EKU)) { - throw new Error( - `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 aca1600..a5ea1a6 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 @@ -14,7 +14,6 @@ import { WindowsInstaller } from "./windows"; jest.mock("fs"); jest.mock("./windows-signature", () => ({ verifyAuthenticodeSignature: jest.fn().mockResolvedValue(undefined), - isAzureSignedEra: jest.fn().mockReturnValue(true), })); 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 d43ac73..a069d95 100644 --- a/src/op-cli-installer/github-action/cli-installer/windows.ts +++ b/src/op-cli-installer/github-action/cli-installer/windows.ts @@ -10,10 +10,7 @@ import { type SupportedPlatform, } from "./cli-installer"; import type { Installer } from "./installer"; -import { - isAzureSignedEra, - verifyAuthenticodeSignature, -} from "./windows-signature"; +import { verifyAuthenticodeSignature } from "./windows-signature"; export class WindowsInstaller extends CliInstaller implements Installer { private readonly platform: SupportedPlatform = "win32"; // Node.js platform identifier for Windows @@ -38,15 +35,7 @@ export class WindowsInstaller extends CliInstaller implements Installer { const extractedPath = await tc.extractZip(zipPath); core.info("Verifying 1Password CLI signature"); - const opExePath = path.join(extractedPath, "op.exe"); - // Azure-era (v2.31.0+): strict Authenticode (matches current docs). - // Sectigo-era (pre-v2.31.0): loose Authenticode (Subject + Status only; - // the Sectigo cert lacks the Microsoft issuer and publisher EKU). - await verifyAuthenticodeSignature( - opExePath, - undefined, - isAzureSignedEra(this.version), - ); + await verifyAuthenticodeSignature(path.join(extractedPath, "op.exe")); core.info("1Password CLI signature verified"); core.addPath(extractedPath);