Add gpg fallback

This commit is contained in:
Jill Regan
2026-05-21 11:22:18 -04:00
parent da7c7c6490
commit d463472f19
9 changed files with 197 additions and 147 deletions
@@ -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<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 === "--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/,
);
});
});
@@ -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<string> => {
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 <fingerprint>
// gpg --verify <sigPath> <opPath>
export const verifyGpgSignature = async (
opPath: string,
sigPath: string,
runGpg: (args: readonly string[]) => Promise<string> = defaultGpgRunner,
): Promise<void> => {
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 });
}
};
@@ -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<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(/BAD signature/);
});
});
@@ -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<string> => {
const { stdout } = await execFileAsync("gpg", args);
return stdout;
};
const defaultKeyDownloader = async (url: string): Promise<string> =>
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<string> = defaultGpgRunner,
downloadKey: (url: string) => Promise<string> = defaultKeyDownloader,
): Promise<void> => {
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 });
}
};
@@ -9,7 +9,7 @@ import {
type SupportedPlatform, type SupportedPlatform,
} from "./cli-installer"; } from "./cli-installer";
import type { Installer } from "./installer"; import type { Installer } from "./installer";
import { verifyLinuxSignature } from "./linux-signature"; import { verifyGpgSignature } from "./gpg-signature";
export class LinuxInstaller extends CliInstaller implements Installer { export class LinuxInstaller extends CliInstaller implements Installer {
private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux 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); const extractedPath = await tc.extractZip(downloadPath);
core.info("Verifying 1Password CLI signature"); core.info("Verifying 1Password CLI signature");
await verifyLinuxSignature( await verifyGpgSignature(
path.join(extractedPath, "op"), path.join(extractedPath, "op"),
path.join(extractedPath, "op.sig"), path.join(extractedPath, "op.sig"),
); );
@@ -1,11 +1,12 @@
import { import {
verifyWindowsBinarySignature, isAzureSignedEra,
verifyAuthenticodeSignature,
WINDOWS_ISSUER_CN_PREFIX, WINDOWS_ISSUER_CN_PREFIX,
WINDOWS_PUBLISHER_EKU, WINDOWS_PUBLISHER_EKU,
WINDOWS_SIGNER_SUBJECT_CN, WINDOWS_SIGNER_SUBJECT_CN,
} from "./windows-signature"; } from "./windows-signature";
describe("verifyWindowsBinarySignature", () => { describe("verifyAuthenticodeSignature", () => {
const OP_EXE = "C:\\op\\op.exe"; const OP_EXE = "C:\\op\\op.exe";
const buildAuthenticodeOutput = ({ const buildAuthenticodeOutput = ({
@@ -33,20 +34,18 @@ describe("verifyWindowsBinarySignature", () => {
const powershellRunner = (output: string) => const powershellRunner = (output: string) =>
jest.fn<Promise<string>, [string]>().mockResolvedValue(output); jest.fn<Promise<string>, [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()); const runner = powershellRunner(buildAuthenticodeOutput());
await expect( await expect(
verifyWindowsBinarySignature(OP_EXE, runner), verifyAuthenticodeSignature(OP_EXE, runner),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
}); });
it("throws if the signer Subject is not AgileBits", async () => { it("throws if the signer Subject is not AgileBits", async () => {
const runner = powershellRunner( const runner = powershellRunner(
buildAuthenticodeOutput({ buildAuthenticodeOutput({ subject: "CN=Attacker, O=Attacker, C=US" }),
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/, /does not contain CN=Agilebits/,
); );
}); });
@@ -54,10 +53,11 @@ describe("verifyWindowsBinarySignature", () => {
it("throws if the Issuer is not the expected Microsoft CA", async () => { it("throws if the Issuer is not the expected Microsoft CA", async () => {
const runner = powershellRunner( const runner = powershellRunner(
buildAuthenticodeOutput({ 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/, /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"], 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/, /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);
});
});
@@ -1,6 +1,8 @@
import { execFile } from "child_process"; import { execFile } from "child_process";
import { promisify } from "util"; import { promisify } from "util";
import semver from "semver";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
// Identifying fields of 1Password's Authenticode signing cert for op.exe. // 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 = export const WINDOWS_PUBLISHER_EKU =
"1.3.6.1.4.1.311.97.661420558.769123285.207353056.500447802"; "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 defaultPowerShellRunner = async (script: string): Promise<string> => {
const { stdout } = await execFileAsync("powershell.exe", [ const { stdout } = await execFileAsync("powershell.exe", [
"-NoProfile", "-NoProfile",
@@ -20,9 +38,10 @@ const defaultPowerShellRunner = async (script: string): Promise<string> => {
return stdout; return stdout;
}; };
// Throws unless op.exe at opExePath carries a valid Authenticode signature // Strict Authenticode check against 1Password's Azure Trusted Signing cert.
// from 1Password (AgileBits) issued by Microsoft, with the publisher EKU. // Throws unless Status is Valid, signer is AgileBits, issuer is a Microsoft
export const verifyWindowsBinarySignature = async ( // CS AOC CA, and the publisher EKU is present.
export const verifyAuthenticodeSignature = async (
opExePath: string, opExePath: string,
runPowerShell: (script: string) => Promise<string> = defaultPowerShellRunner, runPowerShell: (script: string) => Promise<string> = defaultPowerShellRunner,
): Promise<void> => { ): Promise<void> => {
@@ -51,15 +70,15 @@ export const verifyWindowsBinarySignature = async (
const status = fieldValue("Status="); const status = fieldValue("Status=");
if (status !== "Valid") { if (status !== "Valid") {
throw new Error( 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. // Confirm the signer is AgileBits, not some other publisher.
const subject = fieldValue("Subject=") ?? ""; const subject = fieldValue("Subject=") ?? "";
if (!subject.includes(`CN=${WINDOWS_SIGNER_SUBJECT_CN},`)) { if (!subject.includes(`CN=${WINDOWS_SIGNER_SUBJECT_CN}`)) {
throw new Error( 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=") ?? ""; const issuer = fieldValue("Issuer=") ?? "";
if (!issuer.includes(`CN=${WINDOWS_ISSUER_CN_PREFIX}`)) { if (!issuer.includes(`CN=${WINDOWS_ISSUER_CN_PREFIX}`)) {
throw new Error( 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)); .map((l) => l.slice("EKU=".length));
if (!ekus.includes(WINDOWS_PUBLISHER_EKU)) { if (!ekus.includes(WINDOWS_PUBLISHER_EKU)) {
throw new Error( 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"}).`,
); );
} }
}; };
@@ -13,7 +13,11 @@ import { WindowsInstaller } from "./windows";
jest.mock("fs"); jest.mock("fs");
jest.mock("./windows-signature", () => ({ 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(() => { afterEach(() => {
@@ -9,8 +9,12 @@ import {
cliUrlBuilder, cliUrlBuilder,
type SupportedPlatform, type SupportedPlatform,
} from "./cli-installer"; } from "./cli-installer";
import { verifyGpgSignature } from "./gpg-signature";
import type { Installer } from "./installer"; import type { Installer } from "./installer";
import { verifyWindowsBinarySignature } from "./windows-signature"; import {
isAzureSignedEra,
verifyAuthenticodeSignature,
} from "./windows-signature";
export class WindowsInstaller extends CliInstaller implements Installer { export class WindowsInstaller extends CliInstaller implements Installer {
private readonly platform: SupportedPlatform = "win32"; // Node.js platform identifier for Windows 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); const extractedPath = await tc.extractZip(zipPath);
core.info("Verifying 1Password CLI signature"); 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.info("1Password CLI signature verified");
core.addPath(extractedPath); core.addPath(extractedPath);