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,
|
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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user