2342f54179
Signing was account-wide (gpg.format/user.signingkey/commit.gpgsign in
~/.gitconfig), so every repository was signed. Now Settings → Git →
Commit signing lists the user's workspaces, each with its own key picker
("Don't sign" by default). PUT /api/me/git/signing/lane {laneId, key}
writes a small file of ours (lane-<id>.gitconfig beside the account's
keys; a member's through write-as) and includes it from ~/.gitconfig
with includeIf "gitdir:<the workspace's git dir>" and
"gitdir:<common dir>/worktrees/ah-<id>-*" (its sessions' worktrees) —
only that workspace signs, from the terminal, the Changes panel or an
agent, and nothing is written into the repository. The git dirs come
from git run in the workspace's folder as its account; a workspace that
runs as another account is EFOREIGN, a folder that is not a repo
ENOREPO. Stopping needs no workspace, so one removed from the dashboard
while it signed is still listed and can be turned off.
The card now lists the keys that can sign, each with Delete (confirm;
deleting a key stops every workspace signing with it), a way out of the
old account-wide setting, and Remove for a signing setup the user made
themselves (PUT /signing {clearForeign: true}: the three settings only,
never their key file). Importing a key no longer turns on signing.
Verified with real signatures: the picked workspace's commits are G,
another repo's and an unrelated worktree's N, a session worktree's G.
335 lines
14 KiB
JavaScript
335 lines
14 KiB
JavaScript
/**
|
|
* @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-<id>-*` 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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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");
|
|
});
|
|
});
|