/** * @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, and taken out (its file kept) on * `clearForeign`; deleting the key stops signing. Per workspace * (`PUT /api/me/git/signing/lane`): only that workspace's commits — and its * sessions' `ah--*` worktrees — are signed, never another repo's, the * user's own includes stay, and deleting the key stops that workspace's too. * @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(); }); }); describe("PUT /api/me/git/signing/lane", () => { const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "me@x", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "me@x", }; const commit = (dir, msg) => execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", msg], { env }); /** `G` signed and verified, `N` not signed. */ const sig = (dir, allowed) => execFileSync( "git", ["-C", dir, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"], { encoding: "utf8" } ).trim(); const lane = async (dir) => { const r = await request("POST", "/api/lanes/ensure", { cwd: dir, exact: true }); return r.body.lane.id; }; it("signs one workspace and its sessions' worktrees, never another repo", async () => { const A = path.join(ROOT, "ws-a"); const B = path.join(ROOT, "ws-b"); const plain = path.join(ROOT, "ws-plain"); for (const d of [A, B]) { execFileSync("git", ["init", "-q", d]); commit(d, "first"); } fs.mkdirSync(plain); const [a, b, p] = [await lane(A), await lane(B), await lane(plain)]; // A user's own include stays whatever we do. execFileSync("git", ["config", "--global", "includeIf.gitdir:/elsewhere/.path", "/mine"]); const k = keygen("lane", ["-t", "ed25519"]); const imp = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text, name: "lane", }); const key = imp.body.imported.id; const allowed = path.join(ROOT, "allowed-lane"); fs.writeFileSync(allowed, `me@x ${k.pub}\n`); const on = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key }); assert.equal(on.status, 200); assert.deepEqual(on.body.laneSigning, [{ laneId: a, key }]); // Nothing account-wide: the global settings stay unset. assert.equal(on.body.signing.state, "off"); assert.equal(gitGet("commit.gpgsign"), ""); commit(A, "in a"); commit(B, "in b"); assert.equal(sig(A, allowed), "G"); assert.equal(sig(B, allowed), "N"); // A session's worktree of A is signed; any other worktree of A is not. const wt = path.join(A, ".claude", "worktrees", `ah-${a}-x1`); const other = path.join(ROOT, "other-wt"); execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s1", wt]); execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s2", other]); commit(wt, "in session worktree"); commit(other, "in other worktree"); assert.equal(sig(wt, allowed), "G"); assert.equal(sig(other, allowed), "N"); // Nothing was written into the repository itself. assert.doesNotMatch(fs.readFileSync(path.join(A, ".git", "config"), "utf8"), /gpgsign/); // Not a repo: refused; an unknown workspace: 404. const notRepo = await request("PUT", "/api/me/git/signing/lane", { laneId: p, key }); assert.equal(notRepo.status, 409); assert.equal(notRepo.body.error.code, "ENOREPO"); const gone = await request("PUT", "/api/me/git/signing/lane", { laneId: 9999, key }); assert.equal(gone.status, 404); // Off: unsigned again, our file gone, the user's include kept. const off = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key: null }); assert.deepEqual(off.body.laneSigning, []); commit(A, "after off"); assert.equal(sig(A, allowed), "N"); assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine"); // A workspace removed from the dashboard while it signed can still be turned off. await request("PUT", "/api/me/git/signing/lane", { laneId: b, key }); await request("POST", `/api/lanes/${b}/remove`, {}); const orphan = await request("GET", "/api/me/git"); assert.deepEqual(orphan.body.laneSigning, [{ laneId: b, key }]); const stopGone = await request("PUT", "/api/me/git/signing/lane", { laneId: b, key: null }); assert.equal(stopGone.status, 200); assert.deepEqual(stopGone.body.laneSigning, []); const b2 = await lane(B); // Deleting the key stops every workspace that signs with it. await request("PUT", "/api/me/git/signing/lane", { laneId: a, key }); await request("PUT", "/api/me/git/signing/lane", { laneId: b2, key }); const del = await request("DELETE", `/api/me/git/ssh-key?id=${key}`); assert.equal(del.status, 200); assert.deepEqual(del.body.laneSigning, []); commit(A, "after the key went"); assert.equal(sig(A, allowed), "N"); assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine"); assert.deepEqual( fs .readdirSync(path.join(ROOT, ".claude", "agenthub-signing")) .filter((n) => n.endsWith(".gitconfig")), [] ); execFileSync("git", ["config", "--global", "--unset-all", "includeIf.gitdir:/elsewhere/.path"]); await removeAll(); }); it("takes out a signing setup the user made themselves, keeping their key file", async () => { const mine = path.join(ROOT, "my-own-key"); fs.writeFileSync(mine, "not ours"); execFileSync("git", ["config", "--global", "user.signingkey", mine]); execFileSync("git", ["config", "--global", "commit.gpgsign", "true"]); const kept = await request("PUT", "/api/me/git/signing", { key: null }); assert.equal(kept.body.signing.state, "foreign"); const r = await request("PUT", "/api/me/git/signing", { key: null, clearForeign: true }); assert.equal(r.status, 200); assert.equal(r.body.signing.state, "off"); assert.equal(gitGet("user.signingkey"), ""); assert.equal(gitGet("commit.gpgsign"), ""); assert.equal(fs.readFileSync(mine, "utf8"), "not ours"); }); });