Files
AgentHub/server/routes/git-identity.js
T
nntrivi2001 7e41c81d53 feat(git-identity): import an existing SSH key and sign commits with it
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.
2026-11-02 16:12:57 +07:00

263 lines
9.6 KiB
JavaScript

/**
* @file HTTP surface for a user's OWN git identity (`server/lib/git-identity.js`),
* mounted at `/api/me/git`: the SSH keys a clone uses — a default ed25519
* and/or rsa one plus named ones (`name`, deleted by `?id=`) for more
* accounts (make, replace, delete — only the PUBLIC key is ever returned) and
* the HTTPS logins (save, delete — `hasToken` only, never a password),
* whether the account's own terminal git uses the keys (`core.sshCommand`),
* the SSH logins per host or alias (`url.<…>.insteadOf` — AWS CodeCommit's
* SSH key ID — each optionally tied to one key), and the commit author (`user.name` /
* `user.email` of the account's global git config — a member's commits fail
* without one; a new SSH key is labelled with that email, else
* `<username>@smartgift.vn`), a key the user already has imported as a
* named one (`POST /ssh-key/import` — the private text goes in, only its
* public half ever comes back), and whether commits are signed with one of
* the keys (`PUT /signing`: `gpg.format ssh` + `user.signingkey` +
* `commit.gpgsign` in the account's `~/.gitconfig`; a signing key the user
* set themselves is replaced only on `replace: true`). The account is the signed-in user's
* own Linux account under S3, else the host's, which only an admin (or local
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
* request, and every write passes the same-origin guard.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const express = require("express");
const identity = require("../lib/git-identity");
const { sameOriginGuard } = require("../lib/same-origin");
const router = express.Router();
const STATUS = {
EBADHOST: 400,
EBADCRED: 400,
EEXISTS: 409,
EMEMBERGONE: 409,
EHOSTIDENTITY: 403,
EBADAUTHOR: 400,
ESTOREUNREADABLE: 409,
EBADKEYTYPE: 400,
EFOREIGNSSH: 409,
EBADSSHUSER: 400,
EBADKEYNAME: 400,
ETOOMANYKEYS: 409,
EBADALIAS: 400,
ENOKEY: 400,
EBADKEY: 400,
EKEYPASSPHRASE: 400,
EFOREIGNSIGN: 409,
};
function fail(res, e) {
res
.status(STATUS[e.code] || 500)
.json({ error: { code: e.code || "EINTERNAL", message: e.message } });
}
async function view(account, username) {
const keys = identity.sshKeysInfo(account);
const terminal = keys.length ? await identity.terminalSshState(account) : undefined;
return {
account: account.kind,
writable: account.kind === "member" || account.writable !== false,
// `ssh`: the first key, as before there could be two; `sshKeys`: all.
ssh: keys[0] ? { ...keys[0], terminal } : null,
sshKeys: keys,
sshTerminal: terminal ?? null,
sshUsers: await identity.listSshUsers(account),
// The regions a CodeCommit key ID saved for "every region" covers.
codecommitRegions: identity.CODECOMMIT_REGIONS,
https: identity.listHttps(account),
signing: await identity.signingState(account),
author: await identity.getAuthor(account),
// What the author email form suggests while none is set.
defaultEmail: identity.defaultEmail(username),
};
}
/** The signed-in user's name, or the OS user's in local mode. */
const usernameOf = (req) => req.user?.username || os.userInfo().username;
/** The request's account; a write to the host's identity needs an admin. */
function accountOf(req, { write = false } = {}) {
const account = identity.accountForRequest(req);
if (write && account.kind === "host" && !account.writable) {
throw Object.assign(new Error("the host's git identity is an admin's to change"), {
code: "EHOSTIDENTITY",
});
}
return account;
}
router.get("/", async (req, res) => {
try {
res.json(await view(accountOf(req), usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
router.post("/ssh-key", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.generateSshKey(account, {
replace: req.body?.replace === true,
type: req.body?.type === "rsa" ? "rsa" : "ed25519",
// A named key sits beside the default one of its type.
name: typeof req.body?.name === "string" ? req.body.name : null,
// The key is labelled with the commit author's email, else the default one.
comment: (await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
});
res.status(201).json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
/**
* Import a private key the user already has: `{privateKey, name?, sign?,
* replace?}`. With `sign`, commits are signed with it at once (one step for
* the user); `replace` lets that take over a signing key they set themselves.
*/
router.post("/ssh-key/import", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const key = await identity.importSshKey(account, {
privateKey: req.body?.privateKey,
name: typeof req.body?.name === "string" ? req.body.name : null,
comment: (await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
});
let signError = null;
if (req.body?.sign === true && key) {
// The key is saved either way; a refused switch is said, not thrown away.
await identity
.setSigning(account, { id: key.id, replace: req.body?.replace === true })
.catch((e) => {
signError = { code: e.code || "EINTERNAL", message: e.message };
});
}
res.status(201).json({ ...(await view(account, usernameOf(req))), imported: key, signError });
} catch (e) {
fail(res, e);
}
});
/** Sign commits with key `{key: id}`, or stop (`{key: null}`); `replace` as above. */
router.put("/signing", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
await identity.setSigning(account, { id, replace: req.body?.replace === true });
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const type = req.query.type === undefined ? undefined : String(req.query.type);
const id = req.query.id === undefined ? undefined : String(req.query.id);
if (!(await identity.removeSshKey(account, { type, id }))) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
}
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
/**
* Whether a plain `git` in the account's own terminal uses the AgentHub key
* (`core.sshCommand` in its `~/.gitconfig`, issue #82). `{on: boolean}`.
*/
router.put("/ssh-terminal", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
if (!identity.sshKeyInfo(account) && req.body?.on === true) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no SSH key" } });
}
await identity.setTerminalSsh(account, req.body?.on === true);
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
/**
* An SSH login (`{host, user, alias?, key?, allRegions?}`) — e.g. AWS
* CodeCommit's SSH key ID, so a plain `ssh://git-codecommit…/` URL logs in
* (`allRegions`: for every CodeCommit region at once); with `alias`, one more
* account on that host (`ssh://<alias>/…`); with `key`, the only key it uses.
* DELETE `/ssh-user/<host or alias>` drops it (`?allRegions=1&user=` every
* region's).
*/
router.put("/ssh-user", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.setSshUser(account, req.body || {});
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
router.delete("/ssh-user/:host", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
// `?allRegions=1[&user=APKA…]` from a CodeCommit host: every region's
// own login (of that key ID) at once.
const opts =
req.query.allRegions === "1"
? {
allRegions: true,
user: typeof req.query.user === "string" && req.query.user ? req.query.user : null,
}
: {};
if (!(await identity.removeSshUser(account, req.params.host, opts))) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such SSH user" } });
}
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
router.post("/https", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
const { host, username, password } = req.body || {};
identity.saveHttps(account, { protocol: "https", host, username, password });
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
router.delete("/https/:host", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
if (!identity.removeHttps(account, "https", req.params.host)) {
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such login" } });
}
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
/** The account's commit author (`git config --global user.name/email`). */
router.put("/author", sameOriginGuard, async (req, res) => {
try {
const account = accountOf(req, { write: true });
await identity.setAuthor(account, req.body || {});
res.json(await view(account, usernameOf(req)));
} catch (e) {
fail(res, e);
}
});
module.exports = router;