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
+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 });