Add check for macos signature

This commit is contained in:
Jill Regan
2026-05-20 14:35:25 -04:00
parent 908aabfade
commit 6ec01615e5
3 changed files with 231 additions and 0 deletions
@@ -12,6 +12,7 @@ import {
type SupportedPlatform, type SupportedPlatform,
} from "./cli-installer"; } from "./cli-installer";
import { type Installer } from "./installer"; import { type Installer } from "./installer";
import { verifyMacOsPackageSignature } from "./signature";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -34,6 +35,9 @@ export class MacOsInstaller extends CliInstaller implements Installer {
const pkgWithExtension = `${pkgPath}.pkg`; const pkgWithExtension = `${pkgPath}.pkg`;
fs.renameSync(pkgPath, pkgWithExtension); fs.renameSync(pkgPath, pkgWithExtension);
core.info("Verifying 1Password CLI signature");
await verifyMacOsPackageSignature(pkgWithExtension);
const expandDir = "temp-pkg"; const expandDir = "temp-pkg";
await execFileAsync("pkgutil", ["--expand", pkgWithExtension, expandDir]); await execFileAsync("pkgutil", ["--expand", pkgWithExtension, expandDir]);
const payloadPath = path.join(expandDir, "op.pkg", "Payload"); const payloadPath = path.join(expandDir, "op.pkg", "Payload");
@@ -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<Promise<string>, [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<Promise<string>, [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<Promise<string>, [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<Promise<string>, [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<Promise<string>, [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<Promise<string>, [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<Promise<string>, [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<Promise<string>, [string]>().mockResolvedValue(
buildPkgutilOutput({
signerFingerprint:
"DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
}),
);
await expect(
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
).rejects.toThrow(/not on the allowlist/);
});
});
@@ -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<string> => {
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<string> = defaultPkgutilRunner,
): Promise<void> => {
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.",
);
}
};