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
+2 -2
View File
File diff suppressed because one or more lines are too long
+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)
);
});
});
+1
View File
@@ -645,6 +645,7 @@
"fx.pasted": "Pasted.",
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
"fx.secret": "Hidden folders at the top of a home (.ssh, .config…) are not editable here — use a terminal. For an SSH key to clone or sign commits: Settings → Git.",
"fx.conflictTitle": "Already there",
"fx.conflictBody": "This name is taken in the destination: {{names}}",
"fx.conflictAll": "Do this for every remaining file",
+26 -4
View File
@@ -883,6 +883,28 @@
"authorName": "Name",
"authorEmail": "Email",
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
"signTitle": "Commit signing",
"signIntro": "Sign every commit — from your terminal, the Changes panel or an agent — with one of your SSH keys, so a host that requires signed commits accepts your push.",
"signOff": "Commits are not signed.",
"signOn": "Every commit is signed with {{key}}.",
"signForeign": "Commits are signed with a key you set up yourself: {{path}}.",
"signKey": "Signing key",
"signNone": "Don't sign",
"signHostStep": "Add this public key to your account on the host as a SIGNING key (Gitea/GitHub: SSH / GPG Keys — on Gitea press Verify), and keep your commit email above the same as the host account's.",
"signReplaceConfirm": "Your git already signs with {{path}}. Switch to {{key}}?",
"signReplace": "Switch",
"signSaved": "Commits are now signed with {{key}}.",
"signStopped": "Commits are no longer signed.",
"importKey": "Import a key you have",
"importTitle": "Import an existing private key",
"importFile": "Choose the key file",
"importPaste": "…or paste the private key (-----BEGIN … PRIVATE KEY-----)",
"importName": "Name (optional)",
"importSign": "Sign my commits with it",
"importNote": "The key must have no passphrase (a dashboard commit has nowhere to type one). It is stored in your own account; only its public half is ever shown again.",
"importSubmit": "Import",
"imported": "Key {{name}} imported.",
"signingBadge": "Signs commits",
"authorSaved": "Commit author saved.",
"title": "Your git identity",
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
@@ -904,9 +926,9 @@
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
"gitHubSub": "ED25519 key — the git@… SSH address",
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
"gitHubStepAdd": "Add it to your account on the host and paste the whole line: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Personal settings → Register SSH key.",
"awsTitle": "AWS CodeCommit",
"awsSub": "RSA 4096 key — AWS accepts only RSA",
"stepCopy": "Copy the public key.",
@@ -932,10 +954,10 @@
"aliasPlaceholder": "name (optional)",
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
"tabGit": "GitHub · GitLab",
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
"tabOther": "Other hosts",
"addKey": "Add another key",
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
"namedGitSub": "ED25519 · for one more account on a git host.",
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
"namedAwsSub": "RSA · for one more AWS account.",
"removeKeyNamed": "Delete key {{name}}"
+1
View File
@@ -645,6 +645,7 @@
"fx.pasted": "Đã dán.",
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
"fx.secret": "Thư mục ẩn ở cấp đầu home (.ssh, .config…) không chỉnh được ở đây — hãy dùng terminal. Cần SSH key để clone hay ký commit: Settings → Git.",
"fx.conflictTitle": "Đã có sẵn",
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
+26 -4
View File
@@ -871,6 +871,28 @@
"authorName": "Tên",
"authorEmail": "Email",
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
"signTitle": "Ký commit",
"signIntro": "Ký mọi commit — trong terminal, ở panel Thay đổi hay do agent tạo — bằng một SSH key của bạn, để host yêu cầu commit có chữ ký chấp nhận lần push của bạn.",
"signOff": "Commit chưa được ký.",
"signOn": "Mọi commit đang được ký bằng {{key}}.",
"signForeign": "Commit đang được ký bằng key bạn tự cấu hình: {{path}}.",
"signKey": "Key dùng để ký",
"signNone": "Không ký",
"signHostStep": "Thêm public key này vào tài khoản trên host dưới dạng key KÝ (Gitea/GitHub: SSH / GPG Keys — trên Gitea bấm Verify), và giữ email commit ở trên trùng với email tài khoản trên host.",
"signReplaceConfirm": "Git của bạn đang ký bằng {{path}}. Chuyển sang {{key}}?",
"signReplace": "Chuyển",
"signSaved": "Commit giờ được ký bằng {{key}}.",
"signStopped": "Commit không còn được ký.",
"importKey": "Nhập key có sẵn",
"importTitle": "Nhập private key có sẵn",
"importFile": "Chọn file key",
"importPaste": "…hoặc dán private key (-----BEGIN … PRIVATE KEY-----)",
"importName": "Tên (không bắt buộc)",
"importSign": "Dùng key này để ký commit",
"importNote": "Key không được có passphrase (commit từ dashboard không có chỗ nhập). Key được lưu trong tài khoản của chính bạn; sau khi nhập chỉ public key được hiển thị.",
"importSubmit": "Nhập",
"imported": "Đã nhập key {{name}}.",
"signingBadge": "Ký commit",
"authorSaved": "Đã lưu tác giả commit.",
"title": "Danh tính git của bạn",
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
@@ -892,9 +914,9 @@
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
"gitHubStepAdd": "Thêm vào tài khoản trên host, dán nguyên dòng: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Cài đặt cá nhân → Đăng ký SSH key.",
"awsTitle": "AWS CodeCommit",
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
"stepCopy": "Copy public key.",
@@ -920,10 +942,10 @@
"aliasPlaceholder": "tên (tuỳ chọn)",
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
"tabGit": "GitHub · GitLab",
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
"tabOther": "Host khác",
"addKey": "Thêm key khác",
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
"namedGitSub": "ED25519 · cho thêm một tài khoản trên git host.",
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
"removeKeyNamed": "Xoá key {{name}}"
+39 -7
View File
@@ -3356,6 +3356,22 @@ export const api = {
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
removeKeyById: (id: string) =>
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
/**
* POST /api/me/git/ssh-key/import — a private key the user already has
* (the file's text); `sign` signs commits with it at once, `replace`
* over a signing key they set themselves. Only its public half returns.
*/
importKey: (body: { privateKey: string; name?: string; sign?: boolean; replace?: boolean }) =>
request<GitIdentity & { imported: GitSshKey | null; signError: ApiErrorBody | null }>(
"/me/git/ssh-key/import",
{ method: "POST", body: JSON.stringify(body) }
),
/** PUT /api/me/git/signing — sign commits with key `key` (its id), or stop (null). */
setSigning: (key: string | null, replace = false) =>
request<GitIdentity>("/me/git/signing", {
method: "PUT",
body: JSON.stringify({ key, replace }),
}),
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
setTerminalSsh: (on: boolean) =>
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
@@ -5232,6 +5248,21 @@ export interface GitCredential {
hasToken: true;
}
/** One SSH key of `GET /api/me/git` — its public half only. */
export interface GitSshKey {
id?: string;
type?: "ed25519" | "rsa";
name?: string | null;
publicKey: string;
fingerprint: string;
}
/** An API error's `{code, message}`. */
export interface ApiErrorBody {
code: string;
message: string;
}
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
export interface GitIdentity {
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
@@ -5249,13 +5280,14 @@ export interface GitIdentity {
* Every key the account holds: the default ED25519 and RSA first, then the
* named ones (`name`; `id` = its file name); absent from an older server.
*/
sshKeys?: {
id?: string;
type?: "ed25519" | "rsa";
name?: string | null;
publicKey: string;
fingerprint: string;
}[];
sshKeys?: GitSshKey[];
/**
* Commit signing (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign`
* in `~/.gitconfig`): `on` with one of these keys (`key` = its id), `off`,
* or `foreign` — a signing key the user set themselves (`path`). Absent
* from an older server.
*/
signing?: { state: "on" | "off" | "foreign"; key: string | null; path: string | null };
/** The terminal switch (shared by both keys); null without a key. */
sshTerminal?: "on" | "off" | "foreign" | null;
/**
+2
View File
@@ -1529,6 +1529,8 @@ cannot collide on `index.lock`.
GET /api/me/git { account: "member"|"host", writable, ssh: { type, publicKey, fingerprint, terminal: "on"|"off"|"foreign" } | null (the first key), sshKeys: [{ id, type, name, publicKey, fingerprint }] (default ed25519, default rsa, then named keys by name; `name` null for a default key, `id` = its file name), sshTerminal, sshUsers: [{ host, user, alias, key }], https: [{ protocol, host, username, hasToken }], author: { name, email }, defaultEmail }
POST /api/me/git/ssh-key Make an SSH key — Body: { replace?, type?: "ed25519" | "rsa", name? } — without `name` the DEFAULT key of that type (`id_ed25519`/`id_rsa`), with `name` (lowercase letters, digits, `-`, ≤ 32; 400 EBADKEYNAME) one more beside it, `id_<type>_<name>` — for another AWS account or a second GitHub user. 409 EEXISTS when that key exists without replace, or the name is taken by a key of the other type; 409 ETOOMANYKEYS past 10 keys → 201, same shape
DELETE /api/me/git/ssh-key Delete one key — `?id=<key id>` (400 EBADKEYNAME for a malformed id), or the default key of `?type=ed25519|rsa` — or every key without either (404 ENOTFOUND, 400 EBADKEYTYPE). A login naming a deleted key goes back to every key; our terminal `core.sshCommand` follows the keys and goes with the last one
POST /api/me/git/ssh-key/import Import a private key the user already has — Body: { privateKey, name?, sign?, replace? } — OpenSSH (`BEGIN OPENSSH PRIVATE KEY`) or PEM (PKCS#1/PKCS#8) text, ed25519 or RSA ≥ 2048; saved as a NAMED key (`name`, else `signing`, `signing-2`…) written like a generated one, so it serves clones too; only `imported` (its public half) comes back. 400 EBADKEY (not a key, a `.pub`, halves that do not match), EKEYPASSPHRASE (an encrypted key — a dashboard commit has nowhere to type the passphrase), EBADKEYTYPE; 409 EEXISTS (the same key already imported, or the name taken), ETOOMANYKEYS. With `sign: true` commits are signed with it at once; a refusal of that step comes back as `signError` (the key stays saved) → 201
PUT /api/me/git/signing Sign commits — Body: { key: <key id> | null, replace? } — writes `gpg.format ssh`, `user.signingkey <the key's PRIVATE file>` (ssh-keygen signs with it; a `.pub` would need an ssh-agent) and `commit.gpgsign true` to the account's `~/.gitconfig`, so terminal, dashboard and agent commits are all signed; `null` removes the three only when the key is ours. 409 EFOREIGNSIGN over a signing key the user set themselves, unless `replace: true`; deleting the signing key stops signing. `GET` answers `signing: { state: "on"|"off"|"foreign", key, path }` (`path` = the user's own key file when foreign)
PUT /api/me/git/ssh-terminal Use the keys for git typed in the account's own terminal — Body: { on } — sets/unsets `core.sshCommand` in its `~/.gitconfig` (every AgentHub key first, then `~/.ssh/id_*`; rewritten as keys are made or deleted while on); on by default when a member makes a key; 409 EFOREIGNSSH over one the user wrote, which is never touched
PUT /api/me/git/ssh-user An SSH login — Body: { host, user, alias?, key?, allRegions? } — `allRegions: true` (a CodeCommit host, no alias — else 400 EBADHOST) writes the rule for EVERY CodeCommit region at once (the list is `codecommitRegions` in `GET /api/me/git`), replacing each region's own; `DELETE /api/me/git/ssh-user/<a CodeCommit host>?allRegions=1&user=<key ID>` removes them all. Otherwise it writes `url."ssh://<user>@<host>/".insteadOf "ssh://<from>/"` to the account's `~/.gitconfig`, `<from>` = `alias` or the host itself (replacing ours for that host/alias), so `ssh://<from>/…` logs in as `user` (AWS CodeCommit: the IAM SSH key ID `APKA…`). `alias` (lowercase letters, digits, `-`, NO dot — 400 EBADALIAS) is one more account on the same host: two AWS accounts clone as `ssh://aws-prod/v1/repos/x` and `ssh://aws-dev/v1/repos/x`. `key` (a key id, 400 ENOKEY) is stored as `agenthub.<from>.sshKey` there: a clone of a `ssh://<from>/…` URL then offers ONLY that key (`IdentitiesOnly`) and persists it into the clone's config; without it every key is offered. An IAM SSH key ID logs in to every region of its AWS account, so a Clone of `ssh://git-codecommit.<region>.amazonaws.com/…` tries every CodeCommit login saved — the region's own first, then other regions' and every named key's (another AWS account), each with its own key: the first the remote accepts (`git ls-remote`), or the only one, and the clone's `origin` then names that user. So a second account's key clones from a plain AWS URL too, with no `ssh://<alias>/` rewrite. 400 EBADHOST / EBADSSHUSER
DELETE /api/me/git/ssh-user/:from Drop the rule of that host or alias and its key link (404 ENOTFOUND); any other `insteadOf` the user wrote is never touched
+1 -1
View File
@@ -886,7 +886,7 @@ What runs as the member, and how:
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making (or importing) a key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. **Commit signing** sits under the author: one picker signs every commit (terminal, Changes panel, agents) with one of their keys — `gpg.format ssh`, `user.signingkey` = that key's private file, `commit.gpgsign true` in their `~/.gitconfig`; a signing key they set up themselves is replaced only after a confirm — and **Import a key you have** takes a key file or paste (OpenSSH or PEM, ed25519 or RSA ≥ 2048, no passphrase: a dashboard commit has nowhere to type one) into the same `~/.config/agenthub/ssh/` as a named key (`signing`, `signing-2`…), written as them, and by default signs with it at once; only its public half is shown again. The SSH keys are shown one TAB per kind of host, each with only its own steps: **Git (GitHub, GitLab, Gitea, Backlog…)** (ED25519, for Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
- the workspace prompt and its context files;
- the AI Proxy settings: no key, only that pane's own token for the gateway;
+206
View File
@@ -0,0 +1,206 @@
/**
* @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();
});
});
+293
View File
@@ -190,6 +190,11 @@ function makeKeyPair(comment) {
const pair = crypto.generateKeyPairSync("ed25519");
const pub = Buffer.from(pair.publicKey.export({ format: "jwk" }).x, "base64url");
const seed = Buffer.from(pair.privateKey.export({ format: "jwk" }).d, "base64url");
return encodeEd25519(seed, pub, comment);
}
/** An ed25519 key (32-byte seed + public half) in OpenSSH's own formats. */
function encodeEd25519(seed, pub, comment) {
const type = Buffer.from("ssh-ed25519");
const pubBlob = Buffer.concat([sshString(type), sshString(pub)]);
const check = crypto.randomBytes(4);
@@ -220,6 +225,182 @@ function makeKeyPair(comment) {
};
}
/** `ssh-rsa …` for an RSA key object (private or public). */
function rsaPublicLine(key, comment) {
const jwk = crypto.createPublicKey(key).export({ format: "jwk" });
const blob = Buffer.concat([
sshString(Buffer.from("ssh-rsa")),
mpint(Buffer.from(jwk.e, "base64url")),
mpint(Buffer.from(jwk.n, "base64url")),
]);
return `ssh-rsa ${blob.toString("base64")} ${comment}\n`;
}
// ── An imported key ────────────────────────────────────────────────────────
/** A pasted key file is a few KB; anything far past that is not one. */
const IMPORT_MAX = 32 * 1024;
const PASSPHRASE_HINT =
"this key has a passphrase — a commit made from the dashboard has nowhere to type it. " +
'Make a copy without one on your own machine (ssh-keygen -p -N "" -f <copy>) and import the copy';
/** Reads SSH wire strings out of a buffer; EBADKEY on a short one. */
function wireReader(buf) {
let at = 0;
const need = (n) => {
if (at + n > buf.length) throw err("EBADKEY", "not a valid SSH private key");
};
return {
u32() {
need(4);
const v = buf.readUInt32BE(at);
at += 4;
return v;
},
bytes() {
const n = this.u32();
need(n);
const v = buf.subarray(at, at + n);
at += n;
return v;
},
};
}
const b64u = (b) => Buffer.from(b).toString("base64url");
const big = (b) => BigInt(`0x${Buffer.from(b).toString("hex") || "0"}`);
const unbig = (n) => {
let h = n.toString(16);
if (h.length % 2) h = `0${h}`;
return Buffer.from(h, "hex");
};
/** An SSH mpint's magnitude without its sign byte. */
const mag = (b) => (b[0] === 0 && b.length > 1 ? b.subarray(1) : b);
/**
* An `openssh-key-v1` file (`BEGIN OPENSSH PRIVATE KEY`) as a KeyObject plus
* its comment. Encrypted ones are refused (EKEYPASSPHRASE): reading them
* needs the passphrase, and signing from the dashboard would need it again.
*/
function parseOpenSsh(text) {
const b64 = text.replace(/-----(BEGIN|END) OPENSSH PRIVATE KEY-----/g, "").replace(/\s+/g, "");
const buf = Buffer.from(b64, "base64");
const magic = Buffer.from("openssh-key-v1\0");
if (!buf.subarray(0, magic.length).equals(magic)) {
throw err("EBADKEY", "not a valid SSH private key");
}
const r = wireReader(buf.subarray(magic.length));
const cipher = r.bytes().toString();
r.bytes(); // kdf name
r.bytes(); // kdf options
if (cipher !== "none") throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
if (r.u32() !== 1) throw err("EBADKEY", "a key file holding one key");
r.bytes(); // the public blob — checked against the private half below
const p = wireReader(r.bytes());
if (p.u32() !== p.u32()) throw err("EBADKEY", "not a valid SSH private key");
const type = p.bytes().toString();
if (type === "ssh-ed25519") {
const pub = p.bytes();
const sk = p.bytes();
if (pub.length !== 32 || sk.length !== 64 || !sk.subarray(32).equals(pub)) {
throw err("EBADKEY", "not a valid SSH private key");
}
const key = crypto.createPrivateKey({
key: { kty: "OKP", crv: "Ed25519", d: b64u(sk.subarray(0, 32)), x: b64u(pub) },
format: "jwk",
});
return { key, comment: p.bytes().toString("utf8") };
}
if (type === "ssh-rsa") {
const n = mag(p.bytes());
const e = mag(p.bytes());
const d = mag(p.bytes());
const qi = mag(p.bytes());
const pp = mag(p.bytes());
const q = mag(p.bytes());
const D = big(d);
const key = crypto.createPrivateKey({
key: {
kty: "RSA",
n: b64u(n),
e: b64u(e),
d: b64u(d),
p: b64u(pp),
q: b64u(q),
dp: b64u(unbig(D % (big(pp) - 1n))),
dq: b64u(unbig(D % (big(q) - 1n))),
qi: b64u(qi),
},
format: "jwk",
});
return { key, comment: p.bytes().toString("utf8") };
}
throw err("EBADKEYTYPE", `an ${type.replace(/^ssh-/, "")} key — import an ed25519 or RSA one`);
}
/**
* A pasted or uploaded private key → `{type, privateKey, publicKey}` in the
* same formats a generated key is written in. Takes OpenSSH's own format and
* the PEM ones (PKCS#1, PKCS#8); refuses an encrypted key (EKEYPASSPHRASE),
* anything but ed25519 or RSA ≥ 2048 bits (EBADKEYTYPE), and a key whose
* halves do not sign and verify as one (EBADKEY).
*/
function parsePrivateKey(text, fallbackComment) {
const src = typeof text === "string" ? text.replace(/\r\n?/g, "\n").trim() : "";
if (!src || src.length > IMPORT_MAX || !/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(src)) {
throw err(
"EBADKEY",
/^ssh-|^ecdsa-/.test(src)
? "that is a PUBLIC key (.pub) — import the private key file next to it"
: "not an SSH private key — the file starts with -----BEGIN … PRIVATE KEY-----"
);
}
let key;
let comment = "";
if (/-----BEGIN OPENSSH PRIVATE KEY-----/.test(src)) {
({ key, comment } = parseOpenSsh(src));
} else {
if (/ENCRYPTED/.test(src)) throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
try {
key = crypto.createPrivateKey(src);
} catch (e) {
if (/passphrase/i.test(String(e?.code || e?.message))) {
throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
}
throw err("EBADKEY", "not a valid SSH private key");
}
}
const kind = key.asymmetricKeyType;
if (kind !== "ed25519" && kind !== "rsa") {
throw err("EBADKEYTYPE", `an ${kind || "unknown"} key — import an ed25519 or RSA one`);
}
if (kind === "rsa" && (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) {
throw err("EBADKEYTYPE", "an RSA key under 2048 bits — too weak to sign with");
}
// The halves belong together: a mangled paste fails here, not at commit time.
const probe = Buffer.from("agenthub key check");
const hash = kind === "rsa" ? "sha256" : null;
if (!crypto.verify(hash, probe, crypto.createPublicKey(key), crypto.sign(hash, probe, key))) {
throw err("EBADKEY", "the key's private and public halves do not match");
}
const label =
String(comment || fallbackComment || "agenthub")
.replace(/[^\w.@+-]/g, "")
.slice(0, 64) || "agenthub";
if (kind === "ed25519") {
const jwk = key.export({ format: "jwk" });
return {
type: "ed25519",
...encodeEd25519(Buffer.from(jwk.d, "base64url"), Buffer.from(jwk.x, "base64url"), label),
};
}
return {
type: "rsa",
privateKey: key.export({ type: "pkcs1", format: "pem" }),
publicKey: rsaPublicLine(key, label),
};
}
/** `SHA256:…` as `ssh-keygen -l` prints it, from a public key line. */
function fingerprint(publicKey) {
const blob = Buffer.from(String(publicKey).trim().split(/\s+/)[1] || "", "base64");
@@ -321,6 +502,109 @@ async function generateSshKey(
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
}
/**
* Import a key the user already has (`privateKey`: the file's text) as a
* NAMED key beside the generated ones — `name`, else `signing`, `signing-2`…
* Written exactly like a generated key (0600, as the member), so it serves
* clones and pushes too; only its public half is ever returned. The same key
* twice is EEXISTS, naming the key that holds it.
*/
async function importSshKey(account, { privateKey, name = null, comment } = {}) {
const parsed = parsePrivateKey(privateKey, comment);
const keys = currentKeys(account);
const fp = fingerprint(parsed.publicKey);
const twin = keys.find((k) => fingerprint(k.publicKey) === fp);
if (twin) throw err("EEXISTS", `this key is already here as ${twin.name ?? twin.type}`);
let keyName = name == null || name === "" ? null : String(name).trim().toLowerCase();
if (keyName === null) {
keyName = "signing";
for (let n = 2; keys.some((k) => k.name === keyName); n++) keyName = `signing-${n}`;
}
if (!KEY_NAME_RE.test(keyName)) {
throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32");
}
if (keys.some((k) => k.name === keyName))
throw err("EEXISTS", `a key named ${keyName} already exists`);
if (keys.length >= MAX_KEYS) throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`);
const id = keyIdFor(parsed.type, keyName);
const file = path.join(paths(account).sshDir, id);
writeText(account, file, parsed.privateKey, { secret: true });
writeText(account, `${file}.pub`, parsed.publicKey, { secret: false });
if (account.kind === "member" || (await terminalSshState(account)) === "on") {
await setTerminalSsh(account, true).catch(() => {});
}
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
}
// ── Commit signing (`~/.gitconfig` gpg.format / user.signingkey) ───────────
/** One global git config value, or "" when unset. */
async function globalGet(account, key) {
const { git } = require("./worktree");
return git(homeOf(account), ["config", "--global", "--get", key]).then(
(r) => r.stdout.trim(),
() => ""
);
}
/**
* Whether the account's commits are signed with one of ITS AgentHub keys:
* `{state: "on", key}` (gpg.format ssh, user.signingkey = that key's private
* file — ssh-keygen signs with the private file, a `.pub` needs an agent the
* dashboard has not got — and commit.gpgsign true), `{state: "off"}`, or
* `{state: "foreign", path}` when the user set a signing key of their own
* (never touched without `replace`).
*/
async function signingState(account) {
const signingKey = await globalGet(account, "user.signingkey");
if (!signingKey) return { state: "off", key: null, path: null };
const k = currentKeys(account).find((x) => x.file === signingKey);
// Not one of our keys — the user's own, or one of ours deleted by hand
// (commits then fail): either way show the path, so a key can be picked.
if (!k) return { state: "foreign", key: null, path: signingKey };
const on =
(await globalGet(account, "gpg.format")) === "ssh" &&
(await globalGet(account, "commit.gpgsign")) === "true";
return { state: on ? "on" : "off", key: on ? k.id : null, path: null };
}
/**
* Sign every commit of the account with key `id` (null = stop signing).
* Over a signing key the user set themselves only with `replace`
* (EFOREIGNSIGN); off removes only our own settings. No value is a secret —
* the key's PATH crosses sudo's argv, never its content.
*/
async function setSigning(account, { id = null, replace = false } = {}) {
const { git } = require("./worktree");
const home = homeOf(account);
const state = await signingState(account);
const unset = (key) =>
git(home, ["config", "--global", "--unset", key]).catch(() => {
/* already unset */
});
if (id == null) {
if (state.state === "foreign") return state;
if ((await globalGet(account, "user.signingkey")) !== "") {
await unset("commit.gpgsign");
await unset("user.signingkey");
await unset("gpg.format");
}
return signingState(account);
}
const k = currentKeys(account).find((x) => x.id === id);
if (!k) throw err("ENOKEY", "no such SSH key");
if (state.state === "foreign" && !replace) {
throw err(
"EFOREIGNSIGN",
`your ~/.gitconfig already signs with ${state.path} — confirm to switch to ${k.name ?? k.type}`
);
}
await git(home, ["config", "--global", "gpg.format", "ssh"]);
await git(home, ["config", "--global", "user.signingkey", k.file]);
await git(home, ["config", "--global", "commit.gpgsign", "true"]);
return signingState(account);
}
/**
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
* neither is given; false when there was none. A login naming a deleted key
@@ -335,6 +619,11 @@ async function removeSshKey(account, which) {
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
);
if (!gone.length) return false;
// Commits signed with a key about to go would all fail: stop signing first.
const signingKey = await globalGet(account, "user.signingkey");
if (signingKey && gone.some((k) => k.file === signingKey)) {
await setSigning(account, { id: null }).catch(() => {});
}
for (const k of gone) {
removeFile(account, k.file);
removeFile(account, `${k.file}.pub`);
@@ -1133,6 +1422,10 @@ module.exports = {
sshKeyInfo,
sshKeysInfo,
generateSshKey,
importSshKey,
parsePrivateKey,
signingState,
setSigning,
removeSshKey,
terminalSshCommand,
terminalSshState,
+39
View File
@@ -459,6 +459,45 @@ const paths = {
responses: { 200: { description: "The identity." } },
},
},
"/api/me/git/ssh-key/import": {
post: {
tags: ["Git identity"],
summary:
"Import a private key the user already has as a named key; with `sign`, sign commits with it at once",
operationId: "importSshKey",
requestBody: json(
{
privateKey: { type: "string", description: "The key file's text (OpenSSH or PEM)." },
name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" },
sign: { type: "boolean" },
replace: { type: "boolean" },
},
["privateKey"]
),
responses: {
201: {
description:
"The identity, plus `imported` (the key's public half) and `signError` (a refused `sign`, else null).",
},
400: { description: "EBADKEY / EKEYPASSPHRASE / EBADKEYTYPE / EBADKEYNAME" },
409: { description: "EEXISTS (the same key, or the name is taken) / ETOOMANYKEYS" },
},
},
},
"/api/me/git/signing": {
put: {
tags: ["Git identity"],
summary:
"Sign every commit with one of the account's keys (`key`: its id), or stop (`key: null`)",
operationId: "setCommitSigning",
requestBody: json({ key: { type: "string", nullable: true }, replace: { type: "boolean" } }),
responses: {
200: { description: "The identity; `signing.state` is on, off or foreign." },
400: { description: "ENOKEY" },
409: { description: "EFOREIGNSIGN — ~/.gitconfig already signs with a key of its own." },
},
},
},
"/api/me/git/ssh-terminal": {
put: {
tags: ["Git identity"],
+50 -1
View File
@@ -9,7 +9,12 @@
* 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`). The account is the signed-in user's
* `<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.
@@ -39,6 +44,9 @@ const STATUS = {
ETOOMANYKEYS: 409,
EBADALIAS: 400,
ENOKEY: 400,
EBADKEY: 400,
EKEYPASSPHRASE: 400,
EFOREIGNSIGN: 409,
};
function fail(res, e) {
@@ -61,6 +69,7 @@ async function view(account, username) {
// 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),
@@ -106,6 +115,46 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
}
});
/**
* 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 });