From 6ec01615e5d64f73146cb04f1ec3ba79232a0437 Mon Sep 17 00:00:00 2001 From: Jill Regan Date: Wed, 20 May 2026 14:35:25 -0400 Subject: [PATCH] Add check for macos signature --- .../github-action/cli-installer/macos.ts | 4 + .../cli-installer/signature.test.ts | 137 ++++++++++++++++++ .../github-action/cli-installer/signature.ts | 90 ++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 src/op-cli-installer/github-action/cli-installer/signature.test.ts create mode 100644 src/op-cli-installer/github-action/cli-installer/signature.ts 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 f5cd7e5..11222bb 100644 --- a/src/op-cli-installer/github-action/cli-installer/macos.ts +++ b/src/op-cli-installer/github-action/cli-installer/macos.ts @@ -12,6 +12,7 @@ import { type SupportedPlatform, } from "./cli-installer"; import { type Installer } from "./installer"; +import { verifyMacOsPackageSignature } from "./signature"; const execFileAsync = promisify(execFile); @@ -34,6 +35,9 @@ export class MacOsInstaller extends CliInstaller implements Installer { const pkgWithExtension = `${pkgPath}.pkg`; fs.renameSync(pkgPath, pkgWithExtension); + core.info("Verifying 1Password CLI signature"); + await verifyMacOsPackageSignature(pkgWithExtension); + const expandDir = "temp-pkg"; await execFileAsync("pkgutil", ["--expand", pkgWithExtension, expandDir]); const payloadPath = path.join(expandDir, "op.pkg", "Payload"); diff --git a/src/op-cli-installer/github-action/cli-installer/signature.test.ts b/src/op-cli-installer/github-action/cli-installer/signature.test.ts new file mode 100644 index 0000000..1343b7d --- /dev/null +++ b/src/op-cli-installer/github-action/cli-installer/signature.test.ts @@ -0,0 +1,137 @@ +import { + ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS, + APPLE_DEVELOPER_TEAM_ID, + verifyMacOsPackageSignature, +} from "./signature"; + +const FIRST_ALLOWED_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[0]!; +const SECOND_ALLOWED_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[1]!; + +const fingerprintAsPkgutilLine = (hex: string): string => { + const bytes = hex.match(/.{2}/g); + if (!bytes) { + throw new Error("invalid hex"); + } + const first = bytes.slice(0, 24).join(" "); + const second = bytes.slice(24).join(" "); + return ` ${first}\n ${second}`; +}; + +const buildPkgutilOutput = ({ + teamId = APPLE_DEVELOPER_TEAM_ID, + signerFingerprint = FIRST_ALLOWED_FINGERPRINT, + includeChain = true, + includeSignerFingerprint = true, +}: { + teamId?: string; + signerFingerprint?: string; + includeChain?: boolean; + includeSignerFingerprint?: boolean; +} = {}): string => { + const signerFingerprintBlock = includeSignerFingerprint + ? ` SHA256 Fingerprint:\n${fingerprintAsPkgutilLine(signerFingerprint)}\n` + : ""; + + const chain = includeChain + ? ` Certificate Chain: + 1. Developer ID Installer: AgileBits Inc. (${teamId}) + Expires: 2027-02-01 22:12:15 +0000 +${signerFingerprintBlock} ------------------------------------------------------------------------ + 2. Developer ID Certification Authority + Expires: 2027-02-01 22:12:15 +0000 + SHA256 Fingerprint: + 7A FC 9D 01 A6 2F 03 A2 DE 96 37 93 6D 4A FE 68 09 0D 2D E1 8D 03 F2 9C + 88 CF B0 B1 BA 63 58 7F + ------------------------------------------------------------------------ + 3. Apple Root CA +` + : ""; + + return `Package "op_apple_universal_v2.30.3.pkg": + Status: signed by a developer certificate issued by Apple for distribution + Signed with a trusted timestamp on: 2024-06-28 16:08:41 +0000 +${chain}`; +}; + +describe("verifyMacOsPackageSignature", () => { + it("passes for a pkg signed with the first allowlisted fingerprint", async () => { + const runner = jest.fn, [string]>().mockResolvedValue( + buildPkgutilOutput({ + signerFingerprint: FIRST_ALLOWED_FINGERPRINT, + }), + ); + await expect( + verifyMacOsPackageSignature("/tmp/op.pkg", runner), + ).resolves.toBeUndefined(); + expect(runner).toHaveBeenCalledWith("/tmp/op.pkg"); + }); + + it("passes for a pkg signed with the second allowlisted fingerprint", async () => { + const runner = jest.fn, [string]>().mockResolvedValue( + buildPkgutilOutput({ + signerFingerprint: SECOND_ALLOWED_FINGERPRINT, + }), + ); + await expect( + verifyMacOsPackageSignature("/tmp/op.pkg", runner), + ).resolves.toBeUndefined(); + }); + + it("normalizes whitespace and case when comparing fingerprints", async () => { + const lowered = FIRST_ALLOWED_FINGERPRINT.toLowerCase(); + const runner = jest + .fn, [string]>() + .mockResolvedValue(buildPkgutilOutput({ signerFingerprint: lowered })); + await expect( + verifyMacOsPackageSignature("/tmp/op.pkg", runner), + ).resolves.toBeUndefined(); + }); + + it("throws if pkgutil exits non-zero", async () => { + const runner = jest + .fn, [string]>() + .mockRejectedValue(new Error("not a package")); + await expect( + verifyMacOsPackageSignature("/tmp/op.pkg", runner), + ).rejects.toThrow(/pkgutil --check-signature errored.*not a package/); + }); + + it("throws if the output has no certificate chain", async () => { + const runner = jest + .fn, [string]>() + .mockResolvedValue('Package "op.pkg":\n Status: no signature\n'); + await expect( + verifyMacOsPackageSignature("/tmp/op.pkg", runner), + ).rejects.toThrow(/could not locate certificate chain/); + }); + + it("throws if the signer cert is not under the AgileBits team ID", async () => { + const runner = jest + .fn, [string]>() + .mockResolvedValue(buildPkgutilOutput({ teamId: "ATTACKER123" })); + await expect( + verifyMacOsPackageSignature("/tmp/op.pkg", runner), + ).rejects.toThrow(/expected developer team ID 2BUA8C4S2C not found/); + }); + + it("throws if the signer cert fingerprint is missing from the output", async () => { + const runner = jest + .fn, [string]>() + .mockResolvedValue(buildPkgutilOutput({ includeSignerFingerprint: false })); + await expect( + verifyMacOsPackageSignature("/tmp/op.pkg", runner), + ).rejects.toThrow(/could not parse signer cert SHA-256 fingerprint/); + }); + + it("throws if the signer cert fingerprint is not on the allowlist", async () => { + const runner = jest.fn, [string]>().mockResolvedValue( + 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/signature.ts b/src/op-cli-installer/github-action/cli-installer/signature.ts new file mode 100644 index 0000000..19f1024 --- /dev/null +++ b/src/op-cli-installer/github-action/cli-installer/signature.ts @@ -0,0 +1,90 @@ +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 => { + 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.", + ); + } +};