/** * @file Tests for importing an SSH key the user already has and signing * commits with it (`POST /api/me/git/ssh-key/import`, `PUT /api/me/git/signing`, * `server/lib/git-identity.js`): OpenSSH and PEM keys of both types read into * the same public half `ssh-keygen -y` prints, only that half returned, the * private file 0600; a passphrase key, a `.pub`, an ecdsa key and the same key * twice refused; a real commit signed and verified by git; a signing key the * user set themselves kept unless replaced; deleting the key stops signing. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const http = require("node:http"); const { execFileSync } = require("node:child_process"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "git-signing-")); process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db"); process.env.CLAUDE_HOME = path.join(ROOT, ".claude"); process.env.DASHBOARD_REMOTE_SYNC_MS = "0"; process.env.DASHBOARD_LIVENESS_PROBE = "0"; process.env.GIT_CONFIG_NOSYSTEM = "1"; process.env.HOME = ROOT; const { createApp } = require("../index"); let app; let base; const KEYS = path.join(ROOT, "keys"); function request(method, p, body) { return fetch(`${base}${p}`, { method, headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) })); } /** A key made by ssh-keygen itself: `{text, pub}` (pub = type + base64). */ function keygen(name, args, pass = "") { const file = path.join(KEYS, name); execFileSync("ssh-keygen", ["-q", ...args, "-N", pass, "-C", "me@x", "-f", file]); const pub = fs.readFileSync(`${file}.pub`, "utf8").split(" ").slice(0, 2).join(" "); return { text: fs.readFileSync(file, "utf8"), pub }; } const pubOf = (line) => line.split(" ").slice(0, 2).join(" "); const gitGet = (k) => { try { return execFileSync("git", ["config", "--global", "--get", k], { encoding: "utf8" }).trim(); } catch { return ""; } }; const removeAll = async () => { await request("PUT", "/api/me/git/signing", { key: null }); await request("DELETE", "/api/me/git/ssh-key"); }; before(async () => { fs.mkdirSync(KEYS); app = http.createServer(createApp()); await new Promise((r) => app.listen(0, "127.0.0.1", r)); base = `http://127.0.0.1:${app.address().port}`; }); after(async () => { await new Promise((r) => app.close(r)); fs.rmSync(ROOT, { recursive: true, force: true }); }); describe("POST /api/me/git/ssh-key/import", () => { it("reads an OpenSSH ed25519 key into the public half ssh-keygen prints", async () => { const k = keygen("ed", ["-t", "ed25519"]); const r = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text }); assert.equal(r.status, 201); assert.equal(pubOf(r.body.imported.publicKey), k.pub); assert.equal(r.body.imported.name, "signing"); assert.equal(r.body.imported.id, "id_ed25519_signing"); // Nothing private in the answer, and the file is the owner's alone. assert.ok(!JSON.stringify(r.body).includes("PRIVATE KEY")); const file = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519_signing"); assert.equal(fs.statSync(file).mode & 0o777, 0o600); // What was written is a key ssh-keygen itself reads back. assert.equal( pubOf(execFileSync("ssh-keygen", ["-y", "-f", file], { encoding: "utf8" }).trim()), k.pub ); await removeAll(); }); it("reads OpenSSH RSA and PEM (PKCS#8) RSA keys", async () => { const k = keygen("rsa", ["-t", "rsa", "-b", "2048"]); const a = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text }); assert.equal(a.status, 201); assert.equal(a.body.imported.id, "id_rsa_signing"); assert.equal(pubOf(a.body.imported.publicKey), k.pub); const pem = crypto .generateKeyPairSync("rsa", { modulusLength: 2048 }) .privateKey.export({ type: "pkcs8", format: "pem" }); const b = await request("POST", "/api/me/git/ssh-key/import", { privateKey: pem, name: "work", }); assert.equal(b.status, 201); assert.equal(b.body.imported.id, "id_rsa_work"); await removeAll(); }); it("refuses a passphrase key, a public key, an ecdsa key and the same key twice", async () => { const enc = keygen("enc", ["-t", "ed25519"], "secret-pass"); const r1 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: enc.text }); assert.equal(r1.status, 400); assert.equal(r1.body.error.code, "EKEYPASSPHRASE"); const r2 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: enc.pub }); assert.equal(r2.body.error.code, "EBADKEY"); assert.match(r2.body.error.message, /PUBLIC key/); const ec = keygen("ec", ["-t", "ecdsa"]); const r3 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: ec.text }); assert.equal(r3.body.error.code, "EBADKEYTYPE"); const k = keygen("twice", ["-t", "ed25519"]); assert.equal( (await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text })).status, 201 ); const again = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text }); assert.equal(again.status, 409); assert.equal(again.body.error.code, "EEXISTS"); // A mangled paste never reaches the disk. const lines = k.text.split("\n"); const i = 3; // inside the private section lines[i] = lines[i].slice(0, 20) + (lines[i][20] === "Q" ? "R" : "Q") + lines[i].slice(21); const bad = await request("POST", "/api/me/git/ssh-key/import", { privateKey: lines.join("\n"), }); assert.equal(bad.status, 400); await removeAll(); }); }); describe("PUT /api/me/git/signing", () => { it("signs a real commit with the imported key in one step", async () => { const k = keygen("sign", ["-t", "ed25519"]); const r = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text, sign: true, }); assert.equal(r.status, 201); assert.equal(r.body.signError, null); assert.deepEqual(r.body.signing, { state: "on", key: "id_ed25519_signing", path: null }); assert.equal(gitGet("gpg.format"), "ssh"); assert.equal(gitGet("commit.gpgsign"), "true"); // The PRIVATE file: ssh-keygen signs with it, no agent needed. assert.match(gitGet("user.signingkey"), /agenthub-ssh\/id_ed25519_signing$/); const repo = path.join(ROOT, "repo"); execFileSync("git", ["init", "-q", repo]); const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "me@x" }; Object.assign(env, { GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "me@x" }); execFileSync("git", ["-C", repo, "commit", "-q", "--allow-empty", "-m", "signed"], { env }); const allowed = path.join(ROOT, "allowed"); fs.writeFileSync(allowed, `me@x ${k.pub}\n`); const status = execFileSync( "git", ["-C", repo, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"], { encoding: "utf8" } ).trim(); assert.equal(status, "G"); // Deleting the signing key stops signing, so commits keep working. await request("DELETE", "/api/me/git/ssh-key?id=id_ed25519_signing"); assert.equal(gitGet("user.signingkey"), ""); assert.equal(gitGet("commit.gpgsign"), ""); await removeAll(); }); it("keeps a signing key the user set themselves unless told to replace it", async () => { execFileSync("git", ["config", "--global", "user.signingkey", "~/.ssh/id_ed25519"]); const k = keygen("mine", ["-t", "ed25519"]); const r = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text, sign: true, }); // The key is saved; only the switch was refused, and said so. assert.equal(r.status, 201); assert.equal(r.body.signError.code, "EFOREIGNSIGN"); assert.deepEqual(r.body.signing, { state: "foreign", key: null, path: "~/.ssh/id_ed25519" }); const off = await request("PUT", "/api/me/git/signing", { key: null }); assert.equal(off.status, 200); assert.equal(gitGet("user.signingkey"), "~/.ssh/id_ed25519"); const on = await request("PUT", "/api/me/git/signing", { key: r.body.imported.id, replace: true, }); assert.equal(on.status, 200); assert.equal(on.body.signing.state, "on"); const stop = await request("PUT", "/api/me/git/signing", { key: null }); assert.equal(stop.body.signing.state, "off"); assert.equal(gitGet("gpg.format"), ""); const none = await request("PUT", "/api/me/git/signing", { key: "id_ed25519_nope" }); assert.equal(none.body.error.code, "ENOKEY"); await removeAll(); }); });