mirror of
https://github.com/1Password/load-secrets-action.git
synced 2026-06-21 06:23:47 +00:00
Add linux check
This commit is contained in:
@@ -2,7 +2,6 @@ import os from "os";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
archMap,
|
archMap,
|
||||||
CliInstaller,
|
|
||||||
cliUrlBuilder,
|
cliUrlBuilder,
|
||||||
type SupportedPlatform,
|
type SupportedPlatform,
|
||||||
} from "./cli-installer";
|
} from "./cli-installer";
|
||||||
@@ -25,9 +24,7 @@ describe("LinuxInstaller", () => {
|
|||||||
|
|
||||||
it("should call install with correct URL", async () => {
|
it("should call install with correct URL", async () => {
|
||||||
const installer = new LinuxInstaller(version);
|
const installer = new LinuxInstaller(version);
|
||||||
const installMock = jest
|
const installMock = jest.spyOn(installer, "install").mockResolvedValue();
|
||||||
.spyOn(CliInstaller.prototype, "install")
|
|
||||||
.mockResolvedValue();
|
|
||||||
|
|
||||||
await installer.installCli();
|
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 {
|
import {
|
||||||
CliInstaller,
|
CliInstaller,
|
||||||
cliUrlBuilder,
|
cliUrlBuilder,
|
||||||
type SupportedPlatform,
|
type SupportedPlatform,
|
||||||
} from "./cli-installer";
|
} from "./cli-installer";
|
||||||
import type { Installer } from "./installer";
|
import type { Installer } from "./installer";
|
||||||
|
import { verifyLinuxSignature } from "./signature";
|
||||||
|
|
||||||
export class LinuxInstaller extends CliInstaller implements Installer {
|
export class LinuxInstaller extends CliInstaller implements Installer {
|
||||||
private readonly platform: SupportedPlatform = "linux"; // Node.js platform identifier for Linux
|
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> {
|
public async installCli(): Promise<void> {
|
||||||
const urlBuilder = cliUrlBuilder[this.platform];
|
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 {
|
import {
|
||||||
ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS,
|
ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS,
|
||||||
APPLE_DEVELOPER_TEAM_ID,
|
APPLE_DEVELOPER_TEAM_ID,
|
||||||
|
ONEPASSWORD_GPG_KEY_FINGERPRINT,
|
||||||
|
ONEPASSWORD_GPG_KEY_URL,
|
||||||
|
verifyLinuxSignature,
|
||||||
verifyMacOsPackageSignature,
|
verifyMacOsPackageSignature,
|
||||||
} from "./signature";
|
} from "./signature";
|
||||||
|
|
||||||
const FIRST_ALLOWED_FINGERPRINT = ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS[0]!;
|
const VALID_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 = ({
|
const buildPkgutilOutput = ({
|
||||||
teamId = APPLE_DEVELOPER_TEAM_ID,
|
teamId = APPLE_DEVELOPER_TEAM_ID,
|
||||||
signerFingerprint = FIRST_ALLOWED_FINGERPRINT,
|
signerFingerprint = VALID_FINGERPRINT,
|
||||||
includeChain = true,
|
|
||||||
includeSignerFingerprint = true,
|
|
||||||
}: {
|
}: {
|
||||||
teamId?: string;
|
teamId?: string;
|
||||||
signerFingerprint?: string;
|
signerFingerprint?: string;
|
||||||
includeChain?: boolean;
|
|
||||||
includeSignerFingerprint?: boolean;
|
|
||||||
} = {}): string => {
|
} = {}): string => {
|
||||||
const signerFingerprintBlock = includeSignerFingerprint
|
const bytes = signerFingerprint.match(/.{2}/g)!;
|
||||||
? ` SHA256 Fingerprint:\n${fingerprintAsPkgutilLine(signerFingerprint)}\n`
|
const fprLines = ` ${bytes.slice(0, 24).join(" ")}\n ${bytes.slice(24).join(" ")}`;
|
||||||
: "";
|
return `Package "op.pkg":
|
||||||
|
Certificate Chain:
|
||||||
const chain = includeChain
|
|
||||||
? ` Certificate Chain:
|
|
||||||
1. Developer ID Installer: AgileBits Inc. (${teamId})
|
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:
|
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
|
${fprLines}
|
||||||
88 CF B0 B1 BA 63 58 7F
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
3. Apple Root CA
|
2. Developer ID Certification Authority
|
||||||
`
|
`;
|
||||||
: "";
|
|
||||||
|
|
||||||
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}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pkgutilRunner = (output: string) =>
|
||||||
|
jest.fn<Promise<string>, [string]>().mockResolvedValue(output);
|
||||||
|
|
||||||
describe("verifyMacOsPackageSignature", () => {
|
describe("verifyMacOsPackageSignature", () => {
|
||||||
it("passes for a pkg signed with the first allowlisted fingerprint", async () => {
|
it("passes for a pkg signed by AgileBits with an allowlisted cert", async () => {
|
||||||
const runner = jest.fn<Promise<string>, [string]>().mockResolvedValue(
|
const runner = pkgutilRunner(buildPkgutilOutput());
|
||||||
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(
|
await expect(
|
||||||
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
|
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
|
||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes whitespace and case when comparing fingerprints", async () => {
|
it("throws if the signer is not under the AgileBits team ID", async () => {
|
||||||
const lowered = FIRST_ALLOWED_FINGERPRINT.toLowerCase();
|
const runner = pkgutilRunner(buildPkgutilOutput({ teamId: "ATTACKER" }));
|
||||||
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(
|
await expect(
|
||||||
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
|
verifyMacOsPackageSignature("/tmp/op.pkg", runner),
|
||||||
).rejects.toThrow(/expected developer team ID 2BUA8C4S2C not found/);
|
).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 () => {
|
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({
|
buildPkgutilOutput({
|
||||||
signerFingerprint:
|
signerFingerprint:
|
||||||
"DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
|
"DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF",
|
||||||
@@ -135,3 +58,63 @@ describe("verifyMacOsPackageSignature", () => {
|
|||||||
).rejects.toThrow(/not on the allowlist/);
|
).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 { execFile } from "child_process";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as os from "os";
|
||||||
|
import * as path from "path";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
import * as tc from "@actions/tool-cache";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
// See https://www.1password.dev/cli/verify.
|
// See https://www.1password.dev/cli/verify.
|
||||||
@@ -13,6 +18,13 @@ export const ALLOWED_MACOS_SIGNING_CERT_FINGERPRINTS = [
|
|||||||
"141DD87B2B231211F1440849798007DF621DE6EB3DAB985BC964EE9704C4A1C1",
|
"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 defaultPkgutilRunner = async (pkgPath: string): Promise<string> => {
|
||||||
const { stdout } = await execFileAsync("pkgutil", [
|
const { stdout } = await execFileAsync("pkgutil", [
|
||||||
"--check-signature",
|
"--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