7e41c81d53
Settings → Git gains Commit signing: one picker signs every commit (terminal, Changes panel, agents) with one of the account's keys — gpg.format ssh, user.signingkey = the key's private file, commit.gpgsign true in its own ~/.gitconfig; a signing key the user set up themselves is replaced only after a confirm (EFOREIGNSIGN without `replace`). Import a key you have takes a key file or paste (OpenSSH or PEM, ed25519 or RSA >= 2048) into ~/.config/agenthub/ssh/ as a named key, written like a generated one (member: write-as, 0600), and by default signs with it in the same step. A passphrase key, a .pub, an ecdsa key, a mismatched paste and the same key twice are refused; only the public half ever comes back. Deleting the signing key stops signing so commits keep working. The SSH tab is renamed "Git (GitHub, GitLab, Gitea, Backlog…)" with the Gitea and Backlog steps, and the File Explorer's ESECRET toast now says that top-level dot folders of a home are edited in a terminal, and where the SSH keys live instead.
207 lines
8.7 KiB
JavaScript
207 lines
8.7 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; deleting the key stops signing.
|
|
* @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();
|
|
});
|
|
});
|