mirror of
https://github.com/1Password/load-secrets-action.git
synced 2026-06-21 14:23:48 +00:00
Add windows check
This commit is contained in:
+3
-58
@@ -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<Promise<string>, [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/);
|
||||
});
|
||||
});
|
||||
@@ -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<string> => {
|
||||
const { stdout } = await execFileAsync("gpg", args);
|
||||
return stdout;
|
||||
};
|
||||
|
||||
const defaultKeyDownloader = async (url: string): Promise<string> =>
|
||||
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<string> = defaultGpgRunner,
|
||||
downloadKey: (url: string) => Promise<string> = defaultKeyDownloader,
|
||||
): Promise<void> => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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<Promise<string>, [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/);
|
||||
});
|
||||
});
|
||||
@@ -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<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> => {
|
||||
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.",
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<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.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const defaultGpgRunner = async (args: readonly string[]): Promise<string> => {
|
||||
const { stdout } = await execFileAsync("gpg", args);
|
||||
return stdout;
|
||||
};
|
||||
|
||||
const defaultKeyDownloader = async (url: string): Promise<string> =>
|
||||
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<string> = defaultGpgRunner,
|
||||
downloadKey: (url: string) => Promise<string> = defaultKeyDownloader,
|
||||
): Promise<void> => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
@@ -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<Promise<string>, [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/);
|
||||
});
|
||||
});
|
||||
@@ -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<string> => {
|
||||
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<string> = defaultPowerShellRunner,
|
||||
): Promise<void> => {
|
||||
// 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"}).`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
import { WindowsInstaller } from "./windows";
|
||||
|
||||
jest.mock("fs");
|
||||
jest.mock("./windows-signature", () => ({
|
||||
verifyWindowsBinarySignature: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user