Last active
August 3, 2026 12:53
-
-
Save waschmittel/00df8a5f46861f234fb1e426c479a449 to your computer and use it in GitHub Desktop.
script to setup git/ssh auth and git signing based on https://gist.github.com/arianvp/5f59f1783e3eaf1a2d4cd8e952bb4acf -- git-ssh (push/pull/...) needs fingerprint, commit-signing does not, but both keys are in the secure enclave
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env zx | |
| // | |
| // resolveIdentity() falls back from exact-label match to "the sole identity | |
| // with this touch policy" so a key created by hand before this script existed | |
| // gets adopted rather than duplicated; ambiguous cases die rather than guess. | |
| // --cleanup deletes by resolved identity one at a time, never via | |
| // `sc_auth delete-all-ctk-identities`, so unrelated FileVault/PIV/login | |
| // smartcard identities are never touched. | |
| import os from 'node:os'; | |
| import path from 'node:path'; | |
| $.verbose = false; | |
| const SCRIPT_NAME = path.basename(new URL(import.meta.url).pathname); | |
| const SSH_DIR = path.join(os.homedir(), '.ssh'); | |
| const PROVIDER = '/usr/lib/ssh-keychain.dylib'; | |
| const PUSH_LABEL = 'ssh-push'; | |
| const SIGN_LABEL = 'ssh-signing'; | |
| const PUSH_PRIV = path.join(SSH_DIR, 'id_ecdsa_sk_rk'); | |
| const PUSH_PUB = `${PUSH_PRIV}.pub`; | |
| const SIGN_PRIV = path.join(SSH_DIR, 'id_ecdsa_sk_rk_signing'); | |
| const SIGN_PUB = `${SIGN_PRIV}.pub`; | |
| const ALLOWED_SIGNERS = path.join(SSH_DIR, 'allowed_signers'); | |
| const CONFIG_FILE = path.join(SSH_DIR, 'config'); | |
| const MARK_BEGIN = '# >>> se-ssh-setup managed block >>>'; | |
| const MARK_END = '# <<< se-ssh-setup managed block <<<'; | |
| const ALLOWED_ARGS = new Set(['setup', 'cleanup', 'copy-public-key', 'h', 'help', '_', '$0']); | |
| for (const key of Object.keys(argv)) { | |
| if (!ALLOWED_ARGS.has(key)) die(`Unknown argument: --${key}`); | |
| } | |
| const WANTS_SETUP = Boolean(argv.setup); | |
| const WANTS_CLEANUP = Boolean(argv.cleanup); | |
| const WANTS_COPY = Boolean(argv['copy-public-key']); | |
| const WANTS_HELP = Boolean(argv.h || argv.help); | |
| if ([WANTS_SETUP, WANTS_CLEANUP, WANTS_COPY].filter(Boolean).length > 1) { | |
| die('pass only one of --setup, --cleanup, --copy-public-key'); | |
| } | |
| const ACTION = WANTS_HELP ? 'help' : WANTS_CLEANUP ? 'cleanup' : WANTS_SETUP ? 'setup' : WANTS_COPY ? 'copy' : 'help'; | |
| function warn(msg) { | |
| console.log(chalk.yellow('⚠'), msg); | |
| } | |
| function die(msg) { | |
| console.error(chalk.red.bold('ERROR:'), msg); | |
| process.exit(1); | |
| } | |
| function printHelp() { | |
| console.log(`${chalk.bold(SCRIPT_NAME)} — macOS Secure Enclave SSH keys for git push + commit signing`); | |
| console.log(); | |
| console.log(` ${chalk.cyan('--setup')} set up a push key (Touch ID) and a signing key (no touch)`); | |
| console.log(` ${chalk.cyan('--cleanup')} remove everything --setup created`); | |
| console.log(` ${chalk.cyan('--copy-public-key')} copy a public key to the clipboard`); | |
| console.log(` ${chalk.cyan('-h, --help')} show this help (default)`); | |
| } | |
| async function commandExists(cmd) { | |
| return (await $`command -v ${cmd}`.nothrow()).exitCode === 0; | |
| } | |
| async function requireMacosTools() { | |
| if (os.platform() !== 'darwin') die('this script only runs on macOS'); | |
| if (!(await commandExists('sc_auth'))) die('sc_auth not found'); | |
| if (!(await commandExists('ssh-keygen'))) die('ssh-keygen not found'); | |
| if (!(await fs.pathExists(PROVIDER))) die(`${PROVIDER} not found (no Secure Enclave SSH support on this system)`); | |
| } | |
| async function listIdentities(sshFormat) { | |
| const flags = sshFormat ? ['-t', 'ssh'] : []; | |
| const out = await $`sc_auth list-ctk-identities ${flags}`.nothrow(); | |
| return out.stdout | |
| .split('\n') | |
| .slice(1) | |
| .map((line) => line.trim()) | |
| .filter(Boolean) | |
| .map((line) => line.split(/\s+/)) | |
| .map(([keyType, hash, prot, label]) => ({ keyType, hash, prot, label })); | |
| } | |
| async function sha1HashForLabel(label) { | |
| return (await listIdentities(false)).find((i) => i.label === label)?.hash; | |
| } | |
| async function sshFingerprintForLabel(label) { | |
| return (await listIdentities(true)).find((i) => i.label === label)?.hash; | |
| } | |
| async function fingerprintOfFile(file) { | |
| const out = await $`ssh-keygen -l -f ${file}`.nothrow(); | |
| return out.stdout.trim().split(/\s+/)[1]; | |
| } | |
| async function resolveIdentity(canonicalLabel, touchPolicy) { | |
| const identities = await listIdentities(false); | |
| const exact = identities.find((i) => i.label === canonicalLabel); | |
| if (exact) return exact; | |
| const candidates = identities.filter((i) => i.prot === touchPolicy); | |
| if (candidates.length > 1) { | |
| die( | |
| `multiple Secure Enclave identities have touch policy '${touchPolicy}' and none is labeled '${canonicalLabel}' ` + | |
| `(${candidates.map((c) => c.label).join(', ')}) — rename/delete the extras with sc_auth so only one remains, then re-run` | |
| ); | |
| } | |
| return candidates[0] ?? null; | |
| } | |
| async function resolveOrCreateIdentity(canonicalLabel, touchPolicy) { | |
| const existing = await resolveIdentity(canonicalLabel, touchPolicy); | |
| if (existing) return existing.label; | |
| await $`sc_auth create-ctk-identity -l ${canonicalLabel} -k p-256-ne -t ${touchPolicy} -N ${canonicalLabel}`.nothrow(); | |
| if (!(await sha1HashForLabel(canonicalLabel))) die(`failed to create identity '${canonicalLabel}'`); | |
| return canonicalLabel; | |
| } | |
| async function runExportPass(stdin) { | |
| await within(async () => { | |
| cd(SSH_DIR); | |
| await $`printf ${stdin} | ssh-keygen -w ${PROVIDER} -K -N ""`.nothrow(); | |
| }); | |
| } | |
| async function exportBothKeys(pushLabel, signLabel) { | |
| const pushFp = await sshFingerprintForLabel(pushLabel); | |
| const signFp = await sshFingerprintForLabel(signLabel); | |
| if (!pushFp) die(`can't find fingerprint for ${pushLabel}`); | |
| if (!signFp) die(`can't find fingerprint for ${signLabel}`); | |
| if ( | |
| (await fs.pathExists(PUSH_PUB)) && | |
| (await fs.pathExists(SIGN_PUB)) && | |
| (await fingerprintOfFile(PUSH_PUB)) === pushFp && | |
| (await fingerprintOfFile(SIGN_PUB)) === signFp | |
| ) { | |
| return; | |
| } | |
| await fs.remove(PUSH_PRIV); | |
| await fs.remove(PUSH_PUB); | |
| await runExportPass('\\nn\\n'); | |
| if (!(await fs.pathExists(PUSH_PUB))) die('first export pass produced no key file'); | |
| const firstFp = await fingerprintOfFile(PUSH_PUB); | |
| const tmpPriv = path.join(os.tmpdir(), `se-ssh-priv-${process.pid}-${Date.now()}`); | |
| const tmpPub = `${tmpPriv}.pub`; | |
| if (firstFp === pushFp) { | |
| await fs.copy(PUSH_PRIV, tmpPriv); | |
| await fs.copy(PUSH_PUB, tmpPub); | |
| } else if (firstFp === signFp) { | |
| await fs.copy(PUSH_PRIV, SIGN_PRIV); | |
| await fs.copy(PUSH_PUB, SIGN_PUB); | |
| } else { | |
| die(`first exported key fingerprint (${firstFp}) matches neither identity`); | |
| } | |
| await runExportPass('\\ny\\ny\\n'); | |
| const secondFp = await fingerprintOfFile(PUSH_PUB); | |
| if (firstFp === pushFp) { | |
| if (secondFp !== signFp) die(`second exported key fingerprint (${secondFp}) != expected signing key`); | |
| await fs.copy(PUSH_PUB, SIGN_PUB, { overwrite: true }); | |
| await fs.copy(PUSH_PRIV, SIGN_PRIV, { overwrite: true }); | |
| await fs.copy(tmpPriv, PUSH_PRIV, { overwrite: true }); | |
| await fs.copy(tmpPub, PUSH_PUB, { overwrite: true }); | |
| await fs.remove(tmpPriv); | |
| await fs.remove(tmpPub); | |
| } else { | |
| if (secondFp !== pushFp) die(`second exported key fingerprint (${secondFp}) != expected push key`); | |
| } | |
| await fs.chmod(PUSH_PRIV, 0o600); | |
| await fs.chmod(SIGN_PRIV, 0o600); | |
| await fs.chmod(PUSH_PUB, 0o644); | |
| await fs.chmod(SIGN_PUB, 0o644); | |
| if ((await fingerprintOfFile(PUSH_PUB)) !== pushFp) die("final push key file doesn't match enclave identity"); | |
| if ((await fingerprintOfFile(SIGN_PUB)) !== signFp) die("final signing key file doesn't match enclave identity"); | |
| } | |
| async function writeSshConfig() { | |
| await fs.ensureDir(SSH_DIR); | |
| await fs.chmod(SSH_DIR, 0o700); | |
| if (!(await fs.pathExists(CONFIG_FILE))) await fs.writeFile(CONFIG_FILE, ''); | |
| await fs.chmod(CONFIG_FILE, 0o600); | |
| const existing = await fs.readFile(CONFIG_FILE, 'utf8'); | |
| if (existing.includes(MARK_BEGIN)) return; | |
| const block = [ | |
| MARK_BEGIN, | |
| 'Host *', | |
| ' UseKeychain yes', | |
| ' AddKeysToAgent yes', | |
| ` IdentityFile ${PUSH_PRIV}`, | |
| `SecurityKeyProvider ${PROVIDER}`, | |
| MARK_END, | |
| '', | |
| ].join('\n'); | |
| await fs.writeFile(CONFIG_FILE, `${block}\n${existing}`); | |
| } | |
| async function loadAgent() { | |
| await $`ssh-add -S ${PROVIDER} ${PUSH_PRIV}`.nothrow().quiet(); | |
| await $`ssh-add -S ${PROVIDER} ${SIGN_PRIV}`.nothrow().quiet(); | |
| } | |
| async function configureGitSigning() { | |
| const email = (await $`git config --global --get user.email`.nothrow()).stdout.trim(); | |
| if (!email) { | |
| warn('git config --global user.email is not set — skipping allowed_signers entry.'); | |
| warn('Set it, then re-run this script to finish signing setup.'); | |
| } | |
| await $`git config --global gpg.format ssh`; | |
| await $`git config --global user.signingkey ${SIGN_PUB}`; | |
| await $`git config --global commit.gpgsign true`; | |
| await $`git config --global tag.gpgsign true`; | |
| await $`git config --global gpg.ssh.allowedSignersFile ${ALLOWED_SIGNERS}`; | |
| if (email) { | |
| const signPubContent = (await fs.readFile(SIGN_PUB, 'utf8')).trim(); | |
| const existingLines = (await fs.pathExists(ALLOWED_SIGNERS)) | |
| ? (await fs.readFile(ALLOWED_SIGNERS, 'utf8')).split('\n').filter(Boolean) | |
| : []; | |
| const keptLines = existingLines.filter((l) => !l.endsWith('se-ssh-setup')); | |
| keptLines.push(`${email} ${signPubContent} se-ssh-setup`); | |
| await fs.writeFile(ALLOWED_SIGNERS, `${keptLines.join('\n')}\n`); | |
| await fs.chmod(ALLOWED_SIGNERS, 0o600); | |
| } | |
| } | |
| async function doCopyPublicKey() { | |
| if (os.platform() !== 'darwin') die('this script only runs on macOS'); | |
| if (!(await commandExists('pbcopy'))) die('pbcopy not found'); | |
| const target = (await question('Which key to copy? [push/signing]: ')).trim().toLowerCase(); | |
| if (target !== 'push' && target !== 'signing') { | |
| die(`expected 'push' or 'signing', got '${target}'`); | |
| } | |
| const file = target === 'push' ? PUSH_PUB : SIGN_PUB; | |
| if (!(await fs.pathExists(file))) die(`${file} not found — run --setup first`); | |
| const content = (await fs.readFile(file, 'utf8')).trim(); | |
| await $`printf '%s' ${content} | pbcopy`; | |
| console.log(chalk.green(`Copied ${target} public key to clipboard.`)); | |
| } | |
| async function doSetup() { | |
| console.log(chalk.cyan('Setting up Secure Enclave SSH keys...')); | |
| await requireMacosTools(); | |
| await fs.ensureDir(SSH_DIR); | |
| await fs.chmod(SSH_DIR, 0o700); | |
| const pushLabel = await resolveOrCreateIdentity(PUSH_LABEL, 'bio'); | |
| const signLabel = await resolveOrCreateIdentity(SIGN_LABEL, 'none'); | |
| await exportBothKeys(pushLabel, signLabel); | |
| await writeSshConfig(); | |
| await loadAgent(); | |
| await configureGitSigning(); | |
| const pushPubContent = (await fs.readFile(PUSH_PUB, 'utf8')).trim(); | |
| const signPubContent = (await fs.readFile(SIGN_PUB, 'utf8')).trim(); | |
| console.log(); | |
| console.log(chalk.green.bold('Done.')); | |
| console.log(` push key (Touch ID): ${chalk.cyan(PUSH_PUB)}`); | |
| console.log(` signing key (no touch): ${chalk.cyan(SIGN_PUB)}`); | |
| console.log(); | |
| console.log(chalk.bold('Add to GitLab (User Settings -> SSH Keys, or <your-gitlab-host>/-/user_settings/ssh_keys):')); | |
| console.log(` 1. Usage type ${chalk.bold('Authentication')}:`); | |
| console.log(` ${chalk.inverse(pushPubContent)}`); | |
| console.log(); | |
| console.log(` 2. Usage type ${chalk.bold.red('Signing')} — ${chalk.red.bold("do NOT pick 'Authentication & Signing'")}:`); | |
| console.log(` ${chalk.inverse(signPubContent)}`); | |
| console.log(); | |
| console.log(chalk.yellow(' This key has no Touch ID prompt. If GitLab is allowed to use it for')); | |
| console.log(chalk.yellow(' authentication too, anyone with access to this Mac can push as you')); | |
| console.log(chalk.yellow(' with no fingerprint required. It must only ever be used for signing.')); | |
| console.log(chalk.dim(" If your GitLab version has no separate 'Signing' usage type, don't add")); | |
| console.log(chalk.dim(' this key at all — local commit signing still works, you just lose the')); | |
| console.log(chalk.dim(' Verified badge on GitLab.')); | |
| console.log(); | |
| console.log(chalk.dim(`Run './${SCRIPT_NAME} --copy-public-key' to copy either key to the clipboard.`)); | |
| } | |
| async function doCleanup() { | |
| await requireMacosTools(); | |
| const pushIdentity = await resolveIdentity(PUSH_LABEL, 'bio'); | |
| const signIdentity = await resolveIdentity(SIGN_LABEL, 'none'); | |
| const pushDesc = pushIdentity ? `'${pushIdentity.label}'` : `'${PUSH_LABEL}' (not found)`; | |
| const signDesc = signIdentity ? `'${signIdentity.label}'` : `'${SIGN_LABEL}' (not found)`; | |
| console.log(chalk.bold('This will:')); | |
| console.log(` - delete Secure Enclave identities ${chalk.magenta(pushDesc)} and ${chalk.magenta(signDesc)}`); | |
| console.log(` - remove ${chalk.cyan(PUSH_PRIV)}, ${chalk.cyan(PUSH_PUB)}, ${chalk.cyan(SIGN_PRIV)}, ${chalk.cyan(SIGN_PUB)}`); | |
| console.log(` - remove this script's block from ${chalk.cyan(CONFIG_FILE)}`); | |
| console.log(` - remove this script's line from ${chalk.cyan(ALLOWED_SIGNERS)}`); | |
| console.log(" - unset git global signing config if it points at these keys"); | |
| console.log(); | |
| console.log(chalk.red('Any existing SSH/GitHub authorization or verified commit history tied to')); | |
| console.log(chalk.red.bold('these keys will stop working. This cannot be undone.')); | |
| const reply = await question("Type 'yes' to proceed: "); | |
| if (reply !== 'yes') { | |
| console.log(chalk.yellow('Aborted.')); | |
| process.exit(1); | |
| } | |
| for (const identity of [pushIdentity, signIdentity]) { | |
| if (identity) await $`sc_auth delete-ctk-identity -h ${identity.hash}`.nothrow(); | |
| } | |
| for (const f of [PUSH_PUB, SIGN_PUB]) { | |
| if (await fs.pathExists(f)) await $`ssh-add -d ${f}`.nothrow(); | |
| } | |
| await fs.remove(PUSH_PRIV); | |
| await fs.remove(PUSH_PUB); | |
| await fs.remove(SIGN_PRIV); | |
| await fs.remove(SIGN_PUB); | |
| if (await fs.pathExists(CONFIG_FILE)) { | |
| const content = await fs.readFile(CONFIG_FILE, 'utf8'); | |
| if (content.includes(MARK_BEGIN)) { | |
| let skip = false; | |
| const kept = content.split('\n').filter((line) => { | |
| if (line === MARK_BEGIN) { skip = true; return false; } | |
| if (line === MARK_END) { skip = false; return false; } | |
| return !skip; | |
| }); | |
| await fs.writeFile(CONFIG_FILE, kept.join('\n')); | |
| } | |
| } | |
| if (await fs.pathExists(ALLOWED_SIGNERS)) { | |
| const kept = (await fs.readFile(ALLOWED_SIGNERS, 'utf8')) | |
| .split('\n') | |
| .filter(Boolean) | |
| .filter((l) => !l.endsWith('se-ssh-setup')); | |
| if (kept.length) { | |
| await fs.writeFile(ALLOWED_SIGNERS, `${kept.join('\n')}\n`); | |
| } else { | |
| await fs.remove(ALLOWED_SIGNERS); | |
| } | |
| } | |
| const currentSigningKey = (await $`git config --global --get user.signingkey`.nothrow()).stdout.trim(); | |
| if (currentSigningKey === SIGN_PUB) { | |
| for (const key of ['user.signingkey', 'gpg.format', 'commit.gpgsign', 'tag.gpgsign', 'gpg.ssh.allowedSignersFile']) { | |
| await $`git config --global --unset ${key}`.nothrow(); | |
| } | |
| } | |
| console.log(chalk.green.bold('Cleanup done.')); | |
| } | |
| switch (ACTION) { | |
| case 'help': | |
| printHelp(); | |
| break; | |
| case 'setup': | |
| await doSetup(); | |
| break; | |
| case 'cleanup': | |
| await doCleanup(); | |
| break; | |
| case 'copy': | |
| await doCopyPublicKey(); | |
| break; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment