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.
This commit is contained in:
2026-11-02 16:12:57 +07:00
parent 2d6c751fb2
commit 7e41c81d53
15 changed files with 988 additions and 24 deletions
+7 -2
View File
@@ -529,7 +529,13 @@ export function FileBrowser({
await fn();
if (done) toast.success(done);
} catch (e) {
toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e));
toast.error(
errCode(e) === "EUPGRADE"
? t("fx.needsUpgrade")
: errCode(e) === "ESECRET"
? t("fx.secret")
: errText(e)
);
} finally {
setBusy(false);
changed();
@@ -1693,7 +1699,6 @@ export function FileBrowser({
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
</div>
)}
</div>
{ctx.menu && (
@@ -19,14 +19,30 @@
* the next one below) — and, for
* a user with their own Linux account (S3), their own HTTPS
* logins. The host account's HTTPS logins are the shared Git credentials
* table above it, so this lists them only for a member. The private key and
* table above it, so this lists them only for a member. **Commit signing**
* (under the author): one picker signs every commit with one of the keys
* (`PUT /api/me/git/signing`), and **Import a key you have** takes a private
* key file or paste (`POST /api/me/git/ssh-key/import`) and, ticked by
* default, signs with it in the same step. The private key and
* every token are write-only: the server never returns them. A host identity
* seen by a non-admin is read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
import {
Cloud,
Copy,
FileKey,
GitBranch,
KeyRound,
PenLine,
Plus,
RefreshCw,
Server,
Trash2,
Upload,
} from "lucide-react";
import { api, type GitIdentity } from "../../lib/api";
import { confirmDialog } from "../../lib/confirm";
import { copyText } from "../../lib/clipboard";
@@ -216,6 +232,218 @@ function GitHostForm({
);
}
/**
* Commit signing: the key every commit is signed with (one picker — choosing
* a key turns signing on, "Don't sign" off) and Import a key the user already
* has, which signs with it in the same step unless unticked.
*/
function SigningCard({
id,
keys,
keyLabel,
busy,
run,
}: {
id: GitIdentity;
keys: Key[];
keyLabel: (keyId: string | null | undefined) => string;
busy: boolean;
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
}) {
const { t } = useTranslation("settings");
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
const current = signing.state === "on" ? keys.find((k) => k.id === signing.key) : undefined;
const [form, setForm] = useState<{ text: string; name: string; sign: boolean } | null>(null);
/** Sign with `keyId` (null = stop), asking before taking over the user's own setup. */
const choose = async (keyId: string | null) => {
if (keyId && signing.state === "foreign") {
if (
!(await confirmDialog({
title: t("gitIdentity.signReplaceConfirm", {
path: signing.path,
key: keyLabel(keyId),
}),
confirmLabel: t("gitIdentity.signReplace"),
}))
)
return;
}
await run(
() => api.gitIdentity.setSigning(keyId, signing.state === "foreign"),
keyId ? t("gitIdentity.signSaved", { key: keyLabel(keyId) }) : t("gitIdentity.signStopped")
);
};
const doImport = async () => {
if (!form) return;
let imported: string | null = null;
let foreign = false;
const ok = await run(
async () => {
const r = await api.gitIdentity.importKey({
privateKey: form.text,
...(form.name.trim() ? { name: form.name.trim() } : {}),
sign: form.sign,
});
imported = r.imported?.id ?? null;
foreign = r.signError?.code === "EFOREIGNSIGN";
return r;
},
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
);
if (!ok) return;
setForm(null);
// Saved, but the user's own signing setup was left alone: ask once.
if (imported && foreign) await choose(imported);
};
const readFile = async (file: File | undefined) => {
if (!file || !form) return;
// A key file is a few KB; anything far larger is not one.
if (file.size > 32 * 1024) return toast.error(t("gitIdentity.importPaste"));
const text = await file.text();
setForm({ ...form, text });
};
return (
<section
className="space-y-2 rounded-lg border border-border p-3"
aria-label={t("gitIdentity.signTitle")}
>
<div className="flex items-center gap-2">
<PenLine className="h-4 w-4 text-fg-muted" aria-hidden />
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
{t("gitIdentity.signTitle")}
</h3>
{id.writable && !form && (
<button
type="button"
className={BTN}
disabled={busy}
onClick={() => setForm({ text: "", name: "", sign: true })}
>
<Upload className="h-3.5 w-3.5" />
{t("gitIdentity.importKey")}
</button>
)}
</div>
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
<div className="flex flex-wrap items-center gap-2">
<span
data-testid="git-signing-state"
className={`min-w-0 flex-1 text-[11px] ${
signing.state === "on" ? "text-status-success" : "text-fg-secondary"
}`}
>
{signing.state === "on"
? t("gitIdentity.signOn", { key: keyLabel(signing.key) })
: signing.state === "foreign"
? t("gitIdentity.signForeign", { path: signing.path })
: t("gitIdentity.signOff")}
</span>
{keys.length > 0 && (
<select
aria-label={t("gitIdentity.signKey")}
value={signing.state === "on" ? (signing.key ?? "") : ""}
disabled={busy || !id.writable}
onChange={(e) => void choose(e.target.value || null)}
className="select !py-1 !text-xs"
>
<option value="">
{signing.state === "foreign" ? signing.path : t("gitIdentity.signNone")}
</option>
{keys
.filter((k) => k.id)
.map((k) => (
<option key={k.id} value={k.id}>
{keyLabel(k.id)}
</option>
))}
</select>
)}
</div>
{current && (
<Step n={1}>
<div className="flex flex-wrap items-center gap-2">
<span className="flex-1">{t("gitIdentity.signHostStep")}</span>
<button type="button" className={BTN} onClick={() => void copyText(current.publicKey)}>
<Copy className="h-3.5 w-3.5" />
{t("gitIdentity.copy")}
</button>
</div>
<div className="truncate font-mono text-[10px] text-fg-muted">{current.fingerprint}</div>
</Step>
)}
{form && (
<form
className="space-y-2 rounded border border-border p-2.5"
aria-label={t("gitIdentity.importTitle")}
onSubmit={(e) => {
e.preventDefault();
void doImport();
}}
>
<div className="text-xs font-medium text-fg-primary">{t("gitIdentity.importTitle")}</div>
<label className={`${BTN} cursor-pointer`}>
<FileKey className="h-3.5 w-3.5" />
{t("gitIdentity.importFile")}
<input
type="file"
className="sr-only"
onChange={(e) => void readFile(e.target.files?.[0])}
/>
</label>
<textarea
aria-label={t("gitIdentity.importPaste")}
placeholder={t("gitIdentity.importPaste")}
value={form.text}
spellCheck={false}
autoComplete="off"
rows={4}
onChange={(e) => setForm({ ...form, text: e.target.value })}
className={`${INPUT} resize-y font-mono text-[10px]`}
/>
<div className="flex flex-wrap items-center gap-3">
<input
aria-label={t("gitIdentity.importName")}
placeholder={t("gitIdentity.importName")}
value={form.name}
autoComplete="off"
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
className={`${INPUT} !w-40 font-mono`}
/>
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
<input
type="checkbox"
checked={form.sign}
onChange={(e) => setForm({ ...form, sign: e.target.checked })}
/>
{t("gitIdentity.importSign")}
</label>
</div>
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
<div className="flex gap-2">
<button
type="submit"
disabled={busy || !form.text.trim()}
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("gitIdentity.importSubmit")}
</button>
<button type="button" onClick={() => setForm(null)} className={BTN}>
{t("gitCreds.cancel")}
</button>
</div>
</form>
)}
</section>
);
}
export function GitIdentitySettings() {
const { t } = useTranslation("settings");
const [id, setId] = useState<GitIdentity | null>(null);
@@ -572,6 +800,10 @@ export function GitIdentitySettings() {
)}
</form>
{id.signing !== undefined && (
<SigningCard id={id} keys={keys} keyLabel={keyLabel} busy={busy} run={run} />
)}
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
@@ -616,6 +848,11 @@ export function GitIdentitySettings() {
onRemove={() => removeNamed(k.id!)}
>
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
{id.signing?.state === "on" && id.signing.key === k.id && (
<div className="text-[10px] text-status-success">
{t("gitIdentity.signingBadge")}
</div>
)}
<Step n={3}>
{t("gitIdentity.namedGitStepHost", { name: k.name })}
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
@@ -7,7 +7,9 @@
* other host under "Other SSH hosts" — each kind of host in its own tab, so
* the sections never mix; "Add another key" makes a named key as one more
* card in its own tab, a second AWS account is saved from that card under
* the key's name with that key, and a second GitHub key is tied to its host.
* the key's name with that key, and a second GitHub key is tied to its host;
* commit signing: a key picked signs commits, an imported key signs in the
* same step, and taking over the user's own signing key asks first.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
@@ -31,6 +33,8 @@ vi.mock("../../../lib/api", () => ({
saveHttps: vi.fn(),
saveAuthor: vi.fn(),
removeHttps: vi.fn(),
importKey: vi.fn(),
setSigning: vi.fn(),
},
},
}));
@@ -335,4 +339,55 @@ describe("GitIdentitySettings", () => {
});
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
});
it("signs commits with an imported key in one step", async () => {
const signingKey = {
...KEY,
id: "id_ed25519_signing",
type: "ed25519" as const,
name: "signing",
};
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [], signing: { state: "off", key: null, path: null } })
);
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
...view({
sshKeys: [signingKey],
signing: { state: "on", key: "id_ed25519_signing", path: null },
}),
imported: signingKey,
signError: null,
});
render(<GitIdentitySettings />);
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
const form = screen.getByRole("form", { name: "Import an existing private key" });
await userEvent.type(
within(form).getByLabelText(/paste the private key/),
"-----BEGIN OPENSSH PRIVATE KEY-----x"
);
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
sign: true,
});
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent(/signed with signing/);
expect(confirmDialog).not.toHaveBeenCalled();
});
it("asks before taking over a signing key the user set up themselves", async () => {
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
vi.mocked(api.gitIdentity.get).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/id_ed25519" } })
);
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
);
render(<GitIdentitySettings />);
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent("~/.ssh/id_ed25519");
await userEvent.selectOptions(screen.getByLabelText("Signing key"), "id_ed25519");
expect(confirmDialog).toHaveBeenCalled();
await waitFor(() =>
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith("id_ed25519", true)
);
});
});