From d367634d5e7db37c873f5364a109df2863abc45f Mon Sep 17 00:00:00 2001 From: Jill Regan Date: Thu, 21 May 2026 09:32:54 -0400 Subject: [PATCH] Add windows check --- ...nature.test.ts => linux-signature.test.ts} | 61 +------ .../cli-installer/linux-signature.ts | 58 +++++++ .../github-action/cli-installer/linux.ts | 2 +- .../cli-installer/macos-signature.test.ts | 57 +++++++ .../cli-installer/macos-signature.ts | 78 +++++++++ .../github-action/cli-installer/macos.ts | 2 +- .../github-action/cli-installer/signature.ts | 156 ------------------ .../cli-installer/windows-signature.test.ts | 75 +++++++++ .../cli-installer/windows-signature.ts | 82 +++++++++ .../cli-installer/windows.test.ts | 3 + .../github-action/cli-installer/windows.ts | 7 + 11 files changed, 365 insertions(+), 216 deletions(-) rename src/op-cli-installer/github-action/cli-installer/{signature.test.ts => linux-signature.test.ts} (50%) create mode 100644 src/op-cli-installer/github-action/cli-installer/linux-signature.ts create mode 100644 src/op-cli-installer/github-action/cli-installer/macos-signature.test.ts create mode 100644 src/op-cli-installer/github-action/cli-installer/macos-signature.ts delete mode 100644 src/op-cli-installer/github-action/cli-installer/signature.ts create mode 100644 src/op-cli-installer/github-action/cli-installer/windows-signature.test.ts create mode 100644 src/op-cli-installer/github-action/cli-installer/windows-signature.ts diff --git a/src/op-cli-installer/github-action/cli-installer/signature.test.ts b/src/op-cli-installer/github-action/cli-installer/linux-signature.test.ts similarity index 50% rename from src/op-cli-installer/github-action/cli-installer/signature.test.ts rename to src/op-cli-installer/github-action/cli-installer/linux-signature.test.ts index 7daa291..b0d2d2d 100644 --- a/src/op-cli-installer/github-action/cli-installer/signature.test.ts +++ b/src/op-cli-installer/github-action/cli-installer/linux-signature.test.ts @@ -1,67 +1,12 @@ import { - ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS, - APPLE_DEVELOPER_TEAM_ID, ONEPASSWORD_GPG_KEY_FINGERPRINT, ONEPASSWORD_GPG_KEY_URL, verifyLinuxSignature, - verifyMacOsPackageSignature, -} from "./signature"; - -const VALID_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[0]!; - -const buildPkgutilOutput = ({ - teamId = APPLE_DEVELOPER_TEAM_ID, - signerFingerprint = VALID_FINGERPRINT, -}: { - teamId?: string; - signerFingerprint?: string; -} = {}): string => { - 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}) - SHA256 Fingerprint: -${fprLines} - ------------------------------------------------------------------------ - 2. Developer ID Certification Authority -`; -}; - -const pkgutilRunner = (output: string) => - jest.fn, [string]>().mockResolvedValue(output); - -describe("verifyMacOsPackageSignature", () => { - 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("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 not on the allowlist", async () => { - const runner = pkgutilRunner( - buildPkgutilOutput({ - signerFingerprint: - "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", - }), - ); - await expect( - verifyMacOsPackageSignature("/tmp/op.pkg", runner), - ).rejects.toThrow(/not on the allowlist/); - }); -}); +} from "./linux-signature"; describe("verifyLinuxSignature", () => { const OP_PATH = "/tmp/op"; - const SIG_PATH = "/tmp/op.sig"; + const SIG_PATH = `${OP_PATH}.sig`; const CORRECT_FPR = `fpr:::::::::${ONEPASSWORD_GPG_KEY_FINGERPRINT}:\n`; const WRONG_FPR = `fpr:::::::::DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF:\n`; const downloadKey = jest @@ -115,6 +60,6 @@ describe("verifyLinuxSignature", () => { 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/); + ).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 new file mode 100644 index 0000000..ffd8159 --- /dev/null +++ b/src/op-cli-installer/github-action/cli-installer/linux-signature.ts @@ -0,0 +1,58 @@ +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 b65dfe5..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 { verifyLinuxSignature } from "./signature"; +import { verifyLinuxSignature } from "./linux-signature"; export class LinuxInstaller extends CliInstaller implements Installer { private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux diff --git a/src/op-cli-installer/github-action/cli-installer/macos-signature.test.ts b/src/op-cli-installer/github-action/cli-installer/macos-signature.test.ts new file mode 100644 index 0000000..81e27c6 --- /dev/null +++ b/src/op-cli-installer/github-action/cli-installer/macos-signature.test.ts @@ -0,0 +1,57 @@ +import { + ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS, + APPLE_DEVELOPER_TEAM_ID, + verifyMacOsPackageSignature, +} from "./macos-signature"; + +const VALID_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[0]!; + +const buildPkgutilOutput = ({ + teamId = APPLE_DEVELOPER_TEAM_ID, + signerFingerprint = VALID_FINGERPRINT, +}: { + teamId?: string; + signerFingerprint?: string; +} = {}): string => { + 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}) + SHA256 Fingerprint: +${fprLines} + ------------------------------------------------------------------------ + 2. Developer ID Certification Authority +`; +}; + +const pkgutilRunner = (output: string) => + jest.fn, [string]>().mockResolvedValue(output); + +describe("verifyMacOsPackageSignature", () => { + 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("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 not on the allowlist", async () => { + const runner = pkgutilRunner( + buildPkgutilOutput({ + signerFingerprint: + "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", + }), + ); + await expect( + verifyMacOsPackageSignature("/tmp/op.pkg", runner), + ).rejects.toThrow(/not on the allowlist/); + }); +}); diff --git a/src/op-cli-installer/github-action/cli-installer/macos-signature.ts b/src/op-cli-installer/github-action/cli-installer/macos-signature.ts new file mode 100644 index 0000000..247a419 --- /dev/null +++ b/src/op-cli-installer/github-action/cli-installer/macos-signature.ts @@ -0,0 +1,78 @@ +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +// See https://www.1password.dev/cli/verify. +export const APPLE_DEVELOPER_TEAM_ID = "2BUA8C4S2C"; + +// Append-only: old certs stay listed so historical `op` versions still verify. +// See https://www.1password.dev/cli/verify. +export const ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS = [ + "CAB578061B0209FB70934DA344EF6FEBCD3279B1C074C54B0D7D555743B9D89F", + "141DD87B2B231211F1440849798007DF621DE6EB3DAB985BC964EE9704C4A1C1", +]; + +const defaultPkgutilRunner = async (pkgPath: string): Promise => { + const { stdout } = await execFileAsync("pkgutil", [ + "--check-signature", + pkgPath, + ]); + return stdout; +}; + +// Returns just entry 1 (the signer cert) from the chain. +const extractSignerCertSection = (pkgutilOutput: string): string | null => { + const chainStart = pkgutilOutput.indexOf("Certificate Chain:"); + if (chainStart === -1) { + return null; + } + const chainBody = pkgutilOutput.slice(chainStart); + const secondCert = /\n\s*2\.\s/.exec(chainBody); + return secondCert ? chainBody.slice(0, secondCert.index) : chainBody; +}; + +const parseSignerFingerprint = (signerSection: string): string | null => { + const match = /SHA256 Fingerprint:\s*\n((?:[ \t]+[0-9A-Fa-f ]+\n?)+)/.exec( + signerSection, + ); + const captured = match?.[1]; + return captured ? captured.replace(/\s+/g, "").toUpperCase() : null; +}; + +// Hard-fails if the .pkg at pkgPath is not signed by AgileBits Inc. +// (2BUA8C4S2C) with a certificate on the allowlist above. Must run +// before any extraction of the .pkg contents. +export const verifyMacOsPackageSignature = async ( + pkgPath: string, + runPkgutil: (pkgPath: string) => Promise = defaultPkgutilRunner, +): Promise => { + const stdout = await runPkgutil(pkgPath); + + const signerSection = extractSignerCertSection(stdout); + if (!signerSection) { + throw new Error( + `1Password CLI signature verification failed: could not locate certificate chain in pkgutil output.\npkgutil output:\n${stdout}`, + ); + } + + if (!signerSection.includes(`(${APPLE_DEVELOPER_TEAM_ID})`)) { + throw new Error( + `1Password CLI signature verification failed: expected developer team ID ${APPLE_DEVELOPER_TEAM_ID} not found in signer certificate.\npkgutil output:\n${stdout}`, + ); + } + + const signerFingerprint = parseSignerFingerprint(signerSection); + if (!signerFingerprint) { + throw new Error( + `1Password CLI signature verification failed: could not parse signer cert SHA-256 fingerprint.\npkgutil output:\n${stdout}`, + ); + } + + if (!ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS.includes(signerFingerprint)) { + throw new Error( + `1Password CLI signature verification failed: signer cert SHA-256 fingerprint ${signerFingerprint} is not on the allowlist. ` + + "If 1Password has rotated their installer signing cert, this action needs to be updated — please file an issue at https://github.com/1Password/load-secrets-action/issues.", + ); + } +}; diff --git a/src/op-cli-installer/github-action/cli-installer/macos.ts b/src/op-cli-installer/github-action/cli-installer/macos.ts index ac699a7..e0c320a 100644 --- a/src/op-cli-installer/github-action/cli-installer/macos.ts +++ b/src/op-cli-installer/github-action/cli-installer/macos.ts @@ -12,7 +12,7 @@ import { type SupportedPlatform, } from "./cli-installer"; import { type Installer } from "./installer"; -import { verifyMacOsPackageSignature } from "./signature"; +import { verifyMacOsPackageSignature } from "./macos-signature"; const execFileAsync = promisify(execFile); diff --git a/src/op-cli-installer/github-action/cli-installer/signature.ts b/src/op-cli-installer/github-action/cli-installer/signature.ts deleted file mode 100644 index 0ac9c77..0000000 --- a/src/op-cli-installer/github-action/cli-installer/signature.ts +++ /dev/null @@ -1,156 +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); - -// See https://www.1password.dev/cli/verify. -export const APPLE_DEVELOPER_TEAM_ID = "2BUA8C4S2C"; - -// Append-only: old certs stay listed so historical `op` versions still verify. -// See https://www.1password.dev/cli/verify. -export const ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS = [ - "CAB578061B0209FB70934DA344EF6FEBCD3279B1C074C54B0D7D555743B9D89", - "141DD87B2B231211F1440849798007DF621DE6EB3DAB985BC964EE9704C4A1C", -]; - -// 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 = - "3FEF9748469ADBE15DA7CA80AC2D62742012EA2"; -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", - pkgPath, - ]); - return stdout; -}; - -// Returns just entry 1 (the signer cert) from the chain. -const extractSignerCertSection = (pkgutilOutput: string): string | null => { - const chainStart = pkgutilOutput.indexOf("Certificate Chain:"); - if (chainStart === -1) { - return null; - } - const chainBody = pkgutilOutput.slice(chainStart); - const secondCert = /\n\s*2\.\s/.exec(chainBody); - return secondCert ? chainBody.slice(0, secondCert.index) : chainBody; -}; - -const parseSignerFingerprint = (signerSection: string): string | null => { - const match = /SHA256 Fingerprint:\s*\n((?:[ \t]+[0-9A-Fa-f ]+\n?)+)/.exec( - signerSection, - ); - const captured = match?.[1]; - return captured ? captured.replace(/\s+/g, "").toUpperCase() : null; -}; - -// Hard-fails if the .pkg at pkgPath is not signed by AgileBits Inc. -// (2BUA8C4S2C) with a certificate on the allowlist above. Must run -// before any extraction of the .pkg contents. -export const verifyMacOsPackageSignature = async ( - pkgPath: string, - runPkgutil: (pkgPath: string) => Promise = defaultPkgutilRunner, -): Promise => { - let stdout: string; - try { - stdout = await runPkgutil(pkgPath); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error( - `1Password CLI signature verification failed: pkgutil --check-signature errored: ${message}`, - ); - } - - // Get the certificate chain section of the pkgutil output, which contains the relevant info about the signer cert. - // If this is missing, the output is in an unexpected format and we can't verify the signature. - const signerSection = extractSignerCertSection(stdout); - if (!signerSection) { - throw new Error( - `1Password CLI signature verification failed: could not locate certificate chain in pkgutil output.\npkgutil output:\n${stdout}`, - ); - } - - // Check that the signer cert is under the expected Apple Developer Team ID. - if (!signerSection.includes(`(${APPLE_DEVELOPER_TEAM_ID})`)) { - throw new Error( - `1Password CLI signature verification failed: expected developer team ID ${APPLE_DEVELOPER_TEAM_ID} not found in signer certificate.\npkgutil output:\n${stdout}`, - ); - } - - // Parse the signer cert's SHA-256 fingerprint and check it against the allowlist. - const signerFingerprint = parseSignerFingerprint(signerSection); - if (!signerFingerprint) { - throw new Error( - `1Password CLI signature verification failed: could not parse signer cert SHA-256 fingerprint.\npkgutil output:\n${stdout}`, - ); - } - - if (!ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS.includes(signerFingerprint)) { - throw new Error( - `1Password CLI signature verification failed: signer cert SHA-256 fingerprint ${signerFingerprint} is not on the allowlist. ` + - "If 1Password has rotated their installer signing cert, this action needs to be updated — please file an issue at https://github.com/1Password/load-secrets-action/issues.", - ); - } -}; - -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 }); - } -}; 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 new file mode 100644 index 0000000..4a327cd --- /dev/null +++ b/src/op-cli-installer/github-action/cli-installer/windows-signature.test.ts @@ -0,0 +1,75 @@ +import { + verifyWindowsBinarySignature, + WINDOWS_ISSUER_CN, + WINDOWS_PUBLISHER_EKU, + WINDOWS_SIGNER_SUBJECT_CN, +} from "./windows-signature"; + +describe("verifyWindowsBinarySignature", () => { + const OP_EXE = "C:\\op\\op.exe"; + + const buildAuthenticodeOutput = ({ + status = "Valid", + subject = `CN=${WINDOWS_SIGNER_SUBJECT_CN}, O=Agilebits, L=Toronto, S=Ontario, C=CA`, + issuer = `CN=${WINDOWS_ISSUER_CN}, 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"; + + const powershellRunner = (output: string) => + jest.fn, [string]>().mockResolvedValue(output); + + it("passes for op.exe signed by AgileBits with the expected EKU", async () => { + const runner = powershellRunner(buildAuthenticodeOutput()); + await expect( + verifyWindowsBinarySignature(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", + }), + ); + await expect( + verifyWindowsBinarySignature(OP_EXE, runner), + ).rejects.toThrow(/does not contain CN=Agilebits/); + }); + + 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", + }), + ); + await expect( + verifyWindowsBinarySignature(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( + verifyWindowsBinarySignature(OP_EXE, runner), + ).rejects.toThrow(/expected publisher EKU.*not found/); + }); +}); 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 new file mode 100644 index 0000000..0fcf457 --- /dev/null +++ b/src/op-cli-installer/github-action/cli-installer/windows-signature.ts @@ -0,0 +1,82 @@ +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); + +// Identifying fields 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 = "Microsoft ID Verified CS AOC CA 02"; +export const WINDOWS_PUBLISHER_EKU = + "1.3.6.1.4.1.311.97.661420558.769123285.207353056.500447802"; + +const defaultPowerShellRunner = async (script: string): Promise => { + const { stdout } = await execFileAsync("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]); + 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 ( + opExePath: string, + runPowerShell: (script: string) => Promise = defaultPowerShellRunner, +): 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); + const outputLines = output.split("\n").map((l) => l.trim()); + + const fieldValue = (prefix: string): string | undefined => { + const matchingLine = outputLines.find((l) => l.startsWith(prefix)); + if (!matchingLine) { + return undefined; + } + return matchingLine.slice(prefix.length); + }; + + // Reject unsigned or tampered binaries. + 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}`, + ); + } + + // Confirm the signer is AgileBits, not some other publisher. + const subject = fieldValue("Subject=") ?? ""; + 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}.`, + ); + } + + // Confirm the cert was issued by Microsoft's expected code signing CA. + const issuer = fieldValue("Issuer=") ?? ""; + if (!issuer.includes(`CN=${WINDOWS_ISSUER_CN},`)) { + throw new Error( + `1Password CLI signature verification failed: issuer (${issuer}) does not contain CN=${WINDOWS_ISSUER_CN}.`, + ); + } + + const ekus = outputLines + .filter((l) => l.startsWith("EKU=")) + .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"}).`, + ); + } +}; 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 ae89e7e..4474b5c 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 @@ -12,6 +12,9 @@ import { import { WindowsInstaller } from "./windows"; jest.mock("fs"); +jest.mock("./windows-signature", () => ({ + verifyWindowsBinarySignature: jest.fn().mockResolvedValue(undefined), +})); afterEach(() => { jest.restoreAllMocks(); 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 080efad..d840a75 100644 --- a/src/op-cli-installer/github-action/cli-installer/windows.ts +++ b/src/op-cli-installer/github-action/cli-installer/windows.ts @@ -1,4 +1,5 @@ import * as fs from "fs"; +import * as path from "path"; import * as core from "@actions/core"; import * as tc from "@actions/tool-cache"; @@ -9,6 +10,7 @@ import { type SupportedPlatform, } from "./cli-installer"; import type { Installer } from "./installer"; +import { verifyWindowsBinarySignature } from "./windows-signature"; export class WindowsInstaller extends CliInstaller implements Installer { private readonly platform: SupportedPlatform = "win32"; // Node.js platform identifier for Windows @@ -31,6 +33,11 @@ export class WindowsInstaller extends CliInstaller implements Installer { fs.renameSync(downloadPath, zipPath); console.info("Installing 1Password CLI"); const extractedPath = await tc.extractZip(zipPath); + + core.info("Verifying 1Password CLI signature"); + await verifyWindowsBinarySignature(path.join(extractedPath, "op.exe")); + core.info("1Password CLI signature verified"); + core.addPath(extractedPath); core.info("1Password CLI installed"); }