Verify valid signature and signer

This commit is contained in:
Jill Regan
2026-05-21 13:24:48 -04:00
parent d1dad6d749
commit cc789f0882
7 changed files with 34 additions and 185 deletions
@@ -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/);
});
});
@@ -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<string> => {
//
// gpg --keyserver keyserver.ubuntu.com --recv-keys <fingerprint>
// gpg --verify <sigPath> <opPath>
export const verifyGpgSignature = async (
export const verifyLinuxSignature = async (
opPath: string,
sigPath: string,
runGpg: (args: readonly string[]) => Promise<string> = defaultGpgRunner,
@@ -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"),
);
@@ -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<Promise<string>, [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);
});
});
@@ -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<string> => {
const { stdout } = await execFileAsync("powershell.exe", [
@@ -38,33 +17,21 @@ const defaultPowerShellRunner = async (script: string): Promise<string> => {
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<string> = defaultPowerShellRunner,
strict = true,
): Promise<void> => {
// 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"}).`,
);
}
};
@@ -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(() => {
@@ -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);