mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
feat(exe-dev): validate sshPrivateKey at config-save time (PAPA-451)
Inline-parse common SSH private key failure modes (pasted public key, PuTTY .ppk file, truncated paste, header/footer mismatch, non-base64 body) inside onEnvironmentValidateConfig so they surface on the plugin config form instead of after a VM has been provisioned. Empty key still falls back to the on-host key per existing semantics. No host ssh-keygen dependency. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
23cae4cd96
commit
bf8a7c98ea
2 files changed
+170
-2
No files matched your search
@@ -1,5 +1,9 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
@@ -14,7 +18,7 @@ vi.mock("node:child_process", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import plugin from "./plugin.js";
|
||||
import plugin, { validateSshPrivateKey } from "./plugin.js";
|
||||
|
||||
class MockChildProcess extends EventEmitter {
|
||||
stdout = new EventEmitter();
|
||||
@@ -165,6 +169,102 @@ describe("exe.dev sandbox provider plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sshPrivateKey validation", () => {
|
||||
let VALID_OPENSSH: string;
|
||||
let VALID_RSA_PEM: string;
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "exe-dev-ssh-fixture-"));
|
||||
try {
|
||||
const edKey = join(dir, "ed25519");
|
||||
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "fixture", "-f", edKey], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
VALID_OPENSSH = readFileSync(edKey, "utf-8");
|
||||
|
||||
const rsaKey = join(dir, "rsa");
|
||||
execFileSync(
|
||||
"ssh-keygen",
|
||||
["-q", "-t", "rsa", "-b", "2048", "-m", "PEM", "-N", "", "-C", "fixture", "-f", rsaKey],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
VALID_RSA_PEM = readFileSync(rsaKey, "utf-8");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a valid OpenSSH PEM block", () => {
|
||||
expect(validateSshPrivateKey(VALID_OPENSSH)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a valid PKCS#1 RSA PEM block", () => {
|
||||
expect(validateSshPrivateKey(VALID_RSA_PEM)).toBeNull();
|
||||
});
|
||||
|
||||
it("treats empty / whitespace-only input as valid (falls back to on-host key)", () => {
|
||||
expect(validateSshPrivateKey("")).toBeNull();
|
||||
expect(validateSshPrivateKey(" \n\n ")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a pasted public key", () => {
|
||||
expect(
|
||||
validateSshPrivateKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE+gT9 user@host"),
|
||||
).toMatch(/looks like a PUBLIC key/);
|
||||
});
|
||||
|
||||
it("rejects a PuTTY PPK file paste", () => {
|
||||
const ppk = [
|
||||
"PuTTY-User-Key-File-3: ssh-ed25519",
|
||||
"Encryption: none",
|
||||
"Comment: imported-openssh-key",
|
||||
"Public-Lines: 2",
|
||||
"AAAAC3NzaC1lZDI1NTE5AAAAIE+gT9zMxQp4Y6XCfDV2t6oWmqHkKx0K7C7w7q9F6g",
|
||||
"Q3jP",
|
||||
].join("\n");
|
||||
expect(validateSshPrivateKey(ppk)).toMatch(/PuTTY \.ppk/);
|
||||
});
|
||||
|
||||
it("rejects a missing END marker (truncated paste)", () => {
|
||||
const truncated = VALID_OPENSSH.trimEnd().split("\n").slice(0, -1).join("\n");
|
||||
expect(validateSshPrivateKey(truncated)).toMatch(/missing its '-----END/);
|
||||
});
|
||||
|
||||
it("rejects a body with non-base64 characters", () => {
|
||||
const garbled = [
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||
"this is not base64!!",
|
||||
"-----END OPENSSH PRIVATE KEY-----",
|
||||
].join("\n");
|
||||
expect(validateSshPrivateKey(garbled)).toMatch(/non-base64/);
|
||||
});
|
||||
|
||||
it("rejects a header/footer label mismatch", () => {
|
||||
const mismatched = [
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||
"Zm9vYmFy",
|
||||
"-----END RSA PRIVATE KEY-----",
|
||||
].join("\n");
|
||||
expect(validateSshPrivateKey(mismatched)).toMatch(/header\/footer mismatch/);
|
||||
});
|
||||
|
||||
it("returns the sshPrivateKey error from onEnvironmentValidateConfig on save", async () => {
|
||||
process.env.EXE_API_KEY = "host-key";
|
||||
|
||||
const result = await plugin.definition.onEnvironmentValidateConfig?.({
|
||||
driverKey: "exe-dev",
|
||||
config: {
|
||||
sshPrivateKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE+gT9 user@host",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.ok).toBe(false);
|
||||
expect(result?.errors ?? []).toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/sshPrivateKey looks like a PUBLIC key/)]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("acquires a lease by creating a VM and preparing the SSH workspace", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
|
||||
@@ -140,6 +140,70 @@ function isValidUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Catch the SSH-key paste failure modes we've seen in the wild (wrong file,
|
||||
// PPK export, truncated paste) before the user pays the cost of provisioning a
|
||||
// VM and getting a cryptic SSH error. Inline parse — no `ssh-keygen` dependency
|
||||
// — so this also works on hosts where openssh-client isn't installed.
|
||||
export function validateSshPrivateKey(rawKey: string): string | null {
|
||||
const trimmed = rawKey.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
if (/^PuTTY-User-Key-File-\d/m.test(trimmed)) {
|
||||
return "sshPrivateKey looks like a PuTTY .ppk file. Convert it to OpenSSH format (PuTTYgen → Conversions → Export OpenSSH key) and paste the resulting PEM.";
|
||||
}
|
||||
|
||||
if (
|
||||
/^(?:ssh-(?:rsa|dss|ed25519)|ecdsa-sha2-[a-z0-9-]+|sk-(?:ssh-ed25519|ecdsa-sha2-[a-z0-9-]+)@openssh\.com)\s+\S/.test(
|
||||
trimmed,
|
||||
)
|
||||
) {
|
||||
return "sshPrivateKey looks like a PUBLIC key. Paste the matching private key (the file without the .pub extension).";
|
||||
}
|
||||
|
||||
const headerMatch = trimmed.match(/^-----BEGIN ([A-Z0-9 ]*)PRIVATE KEY-----/m);
|
||||
if (!headerMatch) {
|
||||
return "sshPrivateKey must be a PEM-encoded private key starting with a line like '-----BEGIN OPENSSH PRIVATE KEY-----'.";
|
||||
}
|
||||
|
||||
const footerMatch = trimmed.match(/^-----END ([A-Z0-9 ]*)PRIVATE KEY-----\s*$/m);
|
||||
if (!footerMatch) {
|
||||
return "sshPrivateKey is missing its '-----END … PRIVATE KEY-----' footer. Make sure you copied the whole file, including the final line.";
|
||||
}
|
||||
|
||||
const headerLabel = headerMatch[1].trim();
|
||||
const footerLabel = footerMatch[1].trim();
|
||||
if (headerLabel !== footerLabel) {
|
||||
return `sshPrivateKey header/footer mismatch (BEGIN ${headerLabel || "(none)"} vs END ${footerLabel || "(none)"}). The file is likely truncated or two keys are concatenated.`;
|
||||
}
|
||||
|
||||
const headerLineEnd = trimmed.indexOf("\n", headerMatch.index ?? 0);
|
||||
const footerStart = trimmed.lastIndexOf(footerMatch[0]);
|
||||
if (headerLineEnd < 0 || footerStart <= headerLineEnd) {
|
||||
return "sshPrivateKey appears to be empty between its BEGIN and END markers.";
|
||||
}
|
||||
|
||||
const bodyLines = trimmed
|
||||
.slice(headerLineEnd + 1, footerStart)
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
if (bodyLines.length === 0) {
|
||||
return "sshPrivateKey appears to be empty between its BEGIN and END markers.";
|
||||
}
|
||||
|
||||
// PEM bodies are base64 lines, optionally preceded by `Header: value` lines
|
||||
// on encrypted PKCS#1 keys (`Proc-Type:`, `DEK-Info:`).
|
||||
const base64Line = /^[A-Za-z0-9+/=]+$/;
|
||||
const pemHeaderLine = /^[A-Za-z][A-Za-z0-9-]*:\s.+$/;
|
||||
for (const line of bodyLines) {
|
||||
if (!base64Line.test(line) && !pemHeaderLine.test(line)) {
|
||||
return "sshPrivateKey body contains non-base64 characters. The key may have been corrupted by line-wrapping or copy-paste.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeApiUrl(value: string | null): string {
|
||||
if (!value) return DEFAULT_API_URL;
|
||||
const trimmed = value.trim();
|
||||
@@ -694,6 +758,10 @@ const plugin = definePlugin({
|
||||
) {
|
||||
errors.push("strictHostKeyChecking cannot be empty.");
|
||||
}
|
||||
if (config.sshPrivateKey) {
|
||||
const sshKeyError = validateSshPrivateKey(config.sshPrivateKey);
|
||||
if (sshKeyError) errors.push(sshKeyError);
|
||||
}
|
||||
|
||||
warnings.push(
|
||||
"The Paperclip host must have SSH access to the created exe.dev VM, and its SSH key must be registered with exe.dev. The API token only covers provisioning.",
|
||||
|
||||
Reference in new issue
Block a user