mirror of
https://github.com/1Password/load-secrets-action.git
synced 2026-06-20 22:23:47 +00:00
Add linux check
This commit is contained in:
@@ -2,7 +2,6 @@ import os from "os";
|
||||
|
||||
import {
|
||||
archMap,
|
||||
CliInstaller,
|
||||
cliUrlBuilder,
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
@@ -25,9 +24,7 @@ describe("LinuxInstaller", () => {
|
||||
|
||||
it("should call install with correct URL", async () => {
|
||||
const installer = new LinuxInstaller(version);
|
||||
const installMock = jest
|
||||
.spyOn(CliInstaller.prototype, "install")
|
||||
.mockResolvedValue();
|
||||
const installMock = jest.spyOn(installer, "install").mockResolvedValue();
|
||||
|
||||
await installer.installCli();
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import * as path from "path";
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import * as tc from "@actions/tool-cache";
|
||||
|
||||
import {
|
||||
CliInstaller,
|
||||
cliUrlBuilder,
|
||||
type SupportedPlatform,
|
||||
} from "./cli-installer";
|
||||
import type { Installer } from "./installer";
|
||||
import { verifyLinuxSignature } from "./signature";
|
||||
|
||||
export class LinuxInstaller extends CliInstaller implements Installer {
|
||||
private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux
|
||||
@@ -14,6 +20,22 @@ export class LinuxInstaller extends CliInstaller implements Installer {
|
||||
|
||||
public async installCli(): Promise<void> {
|
||||
const urlBuilder = cliUrlBuilder[this.platform];
|
||||
await super.install(urlBuilder(this.version, this.arch));
|
||||
await this.install(urlBuilder(this.version, this.arch));
|
||||
}
|
||||
|
||||
public override async install(url: string): Promise<void> {
|
||||
console.info(`Downloading 1Password CLI from: ${url}`);
|
||||
const downloadPath = await tc.downloadTool(url);
|
||||
console.info("Installing 1Password CLI");
|
||||
const extractedPath = await tc.extractZip(downloadPath);
|
||||
|
||||
core.info("Verifying 1Password CLI signature");
|
||||
await verifyLinuxSignature(
|
||||
path.join(extractedPath, "op"),
|
||||
path.join(extractedPath, "op.sig"),
|
||||
);
|
||||
|
||||
core.addPath(extractedPath);
|
||||
core.info("1Password CLI installed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +1,53 @@
|
||||
import {
|
||||
ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS,
|
||||
APPLE_DEVELOPER_TEAM_ID,
|
||||
ONEPASSWORD_GPG_KEY_FINGERPRINT,
|
||||
ONEPASSWORD_GPG_KEY_URL,
|
||||
verifyLinuxSignature,
|
||||
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 VALID_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[0]!;
|
||||
|
||||
const buildPkgutilOutput = ({
|
||||
teamId = APPLE_DEVELOPER_TEAM_ID,
|
||||
signerFingerprint = FIRST_ALLOWED_FINGERPRINT,
|
||||
includeChain = true,
|
||||
includeSignerFingerprint = true,
|
||||
signerFingerprint = VALID_FINGERPRINT,
|
||||
}: {
|
||||
teamId?: string;
|
||||
signerFingerprint?: string;
|
||||
includeChain?: boolean;
|
||||
includeSignerFingerprint?: boolean;
|
||||
} = {}): string => {
|
||||
const signerFingerprintBlock = includeSignerFingerprint
|
||||
? ` SHA256 Fingerprint:\n${fingerprintAsPkgutilLine(signerFingerprint)}\n`
|
||||
: "";
|
||||
|
||||
const chain = includeChain
|
||||
? ` Certificate Chain:
|
||||
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})
|
||||
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
|
||||
${fprLines}
|
||||
------------------------------------------------------------------------
|
||||
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}`;
|
||||
2. Developer ID Certification Authority
|
||||
`;
|
||||
};
|
||||
|
||||
const pkgutilRunner = (output: string) =>
|
||||
jest.fn<Promise<string>, [string]>().mockResolvedValue(output);
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
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("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" }));
|
||||
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 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(
|
||||
const runner = pkgutilRunner(
|
||||
buildPkgutilOutput({
|
||||
signerFingerprint:
|
||||
"DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
|
||||
@@ -135,3 +58,63 @@ describe("verifyMacOsPackageSignature", () => {
|
||||
).rejects.toThrow(/not on the allowlist/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyLinuxSignature", () => {
|
||||
const OP_PATH = "/tmp/op";
|
||||
const SIG_PATH = "/tmp/op.sig";
|
||||
const CORRECT_FPR = `fpr:::::::::${ONEPASSWORD_GPG_KEY_FINGERPRINT}:\n`;
|
||||
const WRONG_FPR = `fpr:::::::::DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF:\n`;
|
||||
const downloadKey = jest
|
||||
.fn<Promise<string>, [string]>()
|
||||
.mockResolvedValue("/tmp/key.asc");
|
||||
|
||||
beforeEach(() => downloadKey.mockClear());
|
||||
|
||||
const gpgRunner = (...responses: (string | Error)[]) => {
|
||||
const runner = jest.fn<Promise<string>, [readonly string[]]>();
|
||||
for (const r of responses) {
|
||||
if (r instanceof Error) {
|
||||
runner.mockRejectedValueOnce(r);
|
||||
} else {
|
||||
runner.mockResolvedValueOnce(r);
|
||||
}
|
||||
}
|
||||
return runner;
|
||||
};
|
||||
|
||||
const subcommandsCalled = (runner: ReturnType<typeof gpgRunner>) =>
|
||||
runner.mock.calls.map(([args]: [readonly string[]]) =>
|
||||
args.find(
|
||||
(a) => a === "--import" || a === "--list-keys" || a === "--verify",
|
||||
),
|
||||
);
|
||||
|
||||
it("passes when the imported key matches and gpg --verify succeeds", async () => {
|
||||
const runner = gpgRunner("", CORRECT_FPR, "");
|
||||
await expect(
|
||||
verifyLinuxSignature(OP_PATH, SIG_PATH, runner, downloadKey),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(downloadKey).toHaveBeenCalledWith(ONEPASSWORD_GPG_KEY_URL);
|
||||
expect(subcommandsCalled(runner)).toEqual([
|
||||
"--import",
|
||||
"--list-keys",
|
||||
"--verify",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws and skips --verify when the imported key fingerprint is wrong", async () => {
|
||||
const runner = gpgRunner("", WRONG_FPR);
|
||||
await expect(
|
||||
verifyLinuxSignature(OP_PATH, SIG_PATH, runner, downloadKey),
|
||||
).rejects.toThrow(/does not match expected/);
|
||||
expect(subcommandsCalled(runner)).toEqual(["--import", "--list-keys"]);
|
||||
});
|
||||
|
||||
it("throws when gpg --verify rejects the signature", async () => {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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.
|
||||
@@ -13,6 +18,13 @@ export const ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS = [
|
||||
"141DD87B2B231211F1440849798007DF621DE6EB3DAB985BC964EE9704C4A1C1",
|
||||
];
|
||||
|
||||
// 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 defaultPkgutilRunner = async (pkgPath: string): Promise<string> => {
|
||||
const { stdout } = await execFileAsync("pkgutil", [
|
||||
"--check-signature",
|
||||
@@ -88,3 +100,57 @@ export const verifyMacOsPackageSignature = async (
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user