mirror of
https://github.com/1Password/load-secrets-action.git
synced 2026-06-21 06:23:47 +00:00
Add check for macos signature
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user