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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user