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:
Devin FoleyandPaperclip committed 2026-05-29 17:28:21 -07:00
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.",