Привет, Хабр! Я Алексей Шаманов, старший системный архитектор в ГК “Цифра”, занимаюсь проектированием решений на базе платформы ZIIoT. Сегодня предлагаю поговорить об использовании SSH для подписи коммитов в git.
Давным-давно, когда солнце было желтее, трава зеленее, а git действительно «децентрализованным», для определения/верификации авторства вносимых изменений использовался PGP.
То есть, совсем давно вообще ничего подобного не использовалось. И задачи-то в нашей хипстерской «И таааак сойдёт!» коммуне не было — прислали патч по почте, совпал email в коммите с адресом отправителя — замечательно, не совпал — разберёмся (Но это не точно!). Впрочем, во времена столь отдалённые, когда динозавры ещё ходили по земле, мы погружаться не будем и вернемся к PGP.
С одной стороны, задачу определения принадлежности набора изменений метод решал. И неотказуемость обеспечивал. И даже — чьорт побьери! — позволял более-менее подтвердить личность участника в децентрализованной среде без единого root-of-trust. Оборотной стороной являлась некоторая, гм, «хтоничность» настройки какой из вариантов (openpgp, gnupg) ни возьми, да и о каком-либо удобстве использования речь, в общем-то, не идёт.

В общем, сам собой сформировался некоторый консенсус. Штука хорошая, штука важная, штука нужная, но использовать её мы, конечно же, не будем. Ну только если нас прям не заставят. И ведь таки-да, заставляли. В крупных opensource-проектах двухфакторная аутентификация в SCM и commit signing стала, de facto, стандартом. В корпоративном окружении криптографическая подпись вносимых изменений — давно уже составная часть compliance policy безопасной разработки, но сколько-нибудь удобной подобная работа (Особенно в динамической среде с необходимостью работать из разных окружений) всё же не являлась.
С другой стороны, в наши дни git, de facto, — централизованная система с единым root-of-trust и задачу обеспечения доверия можно уже и не решать. Вот что у человека в свойствах профиля на github/gitlab написано, тому и верим. Это открыло возможность использовать для подписи SSH-ключи, которые в наше время есть примерно у всех.
Настройка подписи под Linux
Инструкций по настройке примерно миллион:
shaman@test.lc@haproxy:~$ ssh-keygen -t ed25519 -C "user@test.lc"
Generating public/private ed25519 key pair.
Enter file in which to save the key (/home/shaman@test.lc/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/shaman@test.lc/.ssh/id_ed25519
Your public key has been saved in /home/shaman@test.lc/.ssh/id_ed25519.pub
The key fingerprint is:
SHA256:jfjSeEI4+SQMQMt24+q1mXJ72rb2xy5lxClcHein+rI user@test.lc
The keys randomart image is:
+--[ED25519 256]--+
|o. o.. |
|... o . |
| +.o . + . |
|. oo.o + B . |
| .* + S + |
| . + + |
| . . = O |
|....=+ B o |
| .o*ooEBo |
+----[SHA256]-----+
#Добавим ключ в ssh-agent
shaman@test.lc@haproxy:~$ eval $(ssh-agent -s)
Agent pid 1502
shaman@test.lc@haproxy:~/testrepo$ ssh-add ~/.ssh/id_ed25519
Enter passphrase for /home/shaman@test.lc/.ssh/id_ed25519:
Identity added: /home/shaman@test.lc/.ssh/id_ed25519 (user@test.lc)
echo "user@test.lc $(cat ~/.ssh/id_ed25519.pub)" >> ~/.ssh/allowed_signers
# Создадим репозиторий
shaman@test.lc@haproxy:~$ mkdir testrepo
shaman@test.lc@haproxy:~$ cd testrepo/
shaman@test.lc@haproxy:~/testrepo$ git init
hint: Using 'master' as the name for the initial branch. This default branch name
hint: is subject to change. To configure the initial branch name to use in all
hint: of your new repositories, which will suppress this warning, call:
hint:
hint: git config --global init.defaultBranch <name>
hint:
hint: Names commonly chosen instead of 'master' are 'main', 'trunk' and
hint: 'development'. The just-created branch can be renamed via this command:
hint:
hint: git branch -m <name>
Initialized empty Git repository in /home/shaman@test.lc/testrepo/.git/
# Сконфигурируем git для подписи коммитов
shaman@test.lc@haproxy:~/testrepo$ git config --global gpg.format ssh
shaman@test.lc@haproxy:~/testrepo$ git config --global user.signingkey ~/.ssh/id_ed25519.pub
shaman@test.lc@haproxy:~/testrepo$ git config --global user.name "Aleksey T. Shamanov"
shaman@test.lc@haproxy:~/testrepo$ git config --global user.email user@test.lc
shaman@test.lc@haproxy:~/testrepo$ git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
shaman@test.lc@haproxy:~/testrepo$ git config --global commit.gpgsign true
# Проверим подпись
shaman@test.lc@haproxy:~/testrepo$ echo "Test signing" > ./testfile
shaman@test.lc@haproxy:~/testrepo$ git add testfile
shaman@test.lc@haproxy:~/testrepo$ git commit -m "Test signing"
[master (root-commit) 5a7c088] Test signing
1 file changed, 1 insertion(+)
create mode 100644 testfile
shaman@test.lc@haproxy:~/testrepo$ git log --show-signature
commit 5a7c08803c5b2486907d0e051b4c80cbd7acd71a (HEAD -> master)
Good "git" signature for user@test.lc with ED25519 key SHA256:jfjSeEI4+SQMQMt24+q1mXJ72rb2xy5lxClcHein+rI
Author: Aleksey T. Shamanov <user@test.lc>
Date: Thu Apr 3 10:36:20 2025 +0500
Test signing
Единственная проблема, с которой я столкнулся, была связана с использованием аутентификатора YubiKey, с SSH-ключом, требовавшим подтверждения использования:
ssh-keygen -t ecdsa-sk -O resident -O application=ssh:mainkey -O verify-required
Добавление ключа в ssh-agent требовало явного ввода пин-кода и касания аутентификатора, а ssh-agent не мог этого сделать. Проблему решила установка пакета ssh-askpass. Осталось добавить ключ в github/gitlab, и… Стоп. А в чём, собственно, новизна работы? Точно! Кроме Linux’а есть же ведь еще и Windows! Надеюсь, у вас, как и у меня, уже 10/11 со встроенным OpenSSH, так что всё должно быть просто.
Настройка подписи под Windows
# Проверяем наличие сервиса
PS C:\Users\atsha> Get-Service -Name ssh-agent
Status Name DisplayName
------ ---- -----------
Stopped ssh-agent OpenSSH Authentication Agent
# Настраиваем автоматический запуск и запускаем
PS C:\Users\atsha> Set-Service ssh-agent -StartupType Automatic
PS C:\Users\atsha> Start-Service ssh-agent
# Проверяем, что все работает
PS C:\Users\atsha> Get-Service -Name ssh-agent
Status Name DisplayName
------ ---- -----------
Running ssh-agent OpenSSH Authentication Agent
# Далее аналогично создаем и добавляем ключ
ssh-add "C:\Users\atsha\.ssh\id_ed25519"
PS C:\Users\atsha> ssh-add -l
256 SHA256:... user@test.lc (ED25519)
Полностью аналогично настраиваем git:
git config --global gpg.format ssh
git config --global user.signingkey C:\Users\atsha\.ssh\id_ed25519.pub
git config --global user.name "Aleksey T. Shamanov"
git config --global user.email user@test.lc
git config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signers
git config --global commit.gpgsign true
Проверяем подпись… Первый облом. По умолчанию git использует забандленный OpenSSH, который про встроенный в Windows не знает, знать не хочет и работать с его ssh-agent’ом не умеет. Нет, можно, конечно, настроить поставляемый с git’ом ssh/ssh-agent, поправить .bashrc, но, ээээ… А зачем тогда мы всё вышеописанное делали?
Для использования «системного» OpenSSH есть аж целых три способа:
Переустановить git с использованием внешнего OpenSSH:

2. Явным образом указать «нужный» SSH, задав переменную окружения GIT_SSH_COMMAND
PS C:\Users\atsha> Get-Command ssh.exe
CommandType Name Version Source
----------- ---- ------- ------
Application ssh.exe 9.5.3.1 C:\Windows\System32\OpenSSH\ssh.exe
$env:GIT_SSH_COMMAND = C:\Windows\System32\OpenSSH\ssh.exe
3. Задать путь через конфигурацию: git config --global core.sshCommand "'C:\Windows\System32\OpenSSH\ssh.exe'"
Делаем, проверяем — error: Couldn't get agent socket. Ага. SSH есть, он правильный, но про сокет ssh-agent’а почему-то ничего не знает. А как, собственно, он про него узнает? А, да — по значению переменной SSH_AUTH_SOCK. Проверяем echo $env:SSH_AUTH_SOCK — ну да, пустота. Ok, Google — путь к сокету ssh-agent’а в Windows? Не то… Снова не то… Опять не то… О, вроде как pipe: \\.\pipe\openssh-ssh-agent Задаём значение сразу через системные переменные окружения (чтоб не мучиться с квотингом), проверяем:
PS C:\Users\atsha> echo $env:SSH_AUTH_SOCK
\\.\pipe\openssh-ssh-agent
PS D:\test> git commit -m 'Test signing'
[master (root-commit) f18245d] Test signing
1 file changed, 0 insertions(+), 0 deletions(-)
create mode 100644 testfile
PS D:\test> git log --show-signature
commit f18245d7df2fbed2c8e49b0b65dc52e435fbdd72 (HEAD -> master)
Good "git" signature for user@test.lc with ED25519 key SHA256:...
Author: Aleksey T. Shamanov <user@test.lc>
Date: Thu Apr 3 11:17:25 2025 +0500
Test signing
Voila?! Не-а! Есть ведь ещё и окружение WSL2.
Настройка подписи в WSL2
Каждая проблема имеет решение — простое, понятное и неправильное. Есть оно и у этой задачи — просто берём, копируем ключик в wsl-окружение (Да бог с ним, можно даже не копировать, просто путь указать!) и добавляем его в ssh-agent окружения и проблема решена! Правда назвать это «решение» сколько-нибудь «удобным», кмк, не получится: два раза вводить пароль от ключа при любых подергушках, два независимых SSH-агента — ну, такое себе. Хорошо бы вот ну… один использовать!
И да, возможность есть, и даже не одна! Итак, вариант первый — из палки и верёвки.
Использование npiperelay
В Windows в качестве «unix-socket’ов» используются named pipes, о которых ни Linux, ни WSL 2 примерно ничего не знают. Но мир не без добрых людей, и так появился проект npiperelay, позволяющий получать доступ к Windows named pipes как к unix socket’ам.
На стороне WSL установим socat и протестируем его вызов:
export SSH_AUTH_SOCK=$HOME/.ssh/auth.sock
setsid socat UNIX-LISTEN:"~/.ssh/auth.sock,fork" EXEC:"/mnt/d/npiperelay.ex. -ei -s //./pipe/openssh-ssh-agent"
>/dev/null 2>&1 &
shaman@hwhost:~$ ssh-add -l 256 SHA256:... (ED25519)
Та-дааам! Осталось только запихнуть это дело в .bashrc с проверкой существования сокета и можно пользоваться. Более того, «на сдачу» ещё и обычный ssh с ключом заработает!

И в общем всё в этом способе хорошо, но очень уж развесистая гирлянда выходит. Да и использование niperelay с последним коммитом, сделанным пять лет назад, некоторым образом напрягает. Можно ли обойтись без него? Можно!
Использование Windows OpenSSH в WSL
Обратили внимание на непосредственный вызов npiperelay.exe в прошлом способе? Вспомнили, что из WSL-окружения можно безболезненно вызывать приложения из основной системы? Ну, догадались?
Да, в общем-то никто нам не мешает в качестве core.sshCommand вызывать ssh.exe из состава Microsoft Windows. Уж он-то точно умеет со своим пайпом работать, мы проверяли! Единственный момент, который надо иметь в виду, — необходимость изменения путей под unix-like стандарт:
git config --global core.sshCommand /mnt/c/Windows/System32/OpenSSH/ssh.exe
git config --global user.signingkey /mnt/c/Users/atsha/.ssh/id_ed25519.pub
Работа с IDE
Всё? Ну… да, но нет. Не из консоли ж мы коммитить будем, правда? Нет, бывает что и из неё, но, in general, скорее всего из IDE, и вот тут вполне может быть засада. При создании проекта в окружении WSL IntelliJ автоматически определяет для работы git из окружения WSL:

И всё бы ничего, вот только запускает его IDE в обход твоего .bashrc с заботливо наколбашенным пробросом сокета и/или заменой SSH. Самое «умное», что пришло мне в голову для решения этой задачи, — сделать файлик-обёртку git следующего содержания:
#!/bin/bash
SSH_AUTH_SOCK=$HOME/.ssh/agent.sock /usr/bin/git "$@"
# Путь к сокету будет отличаться в зависимости от способа работы с ним из окружения WSL
…и явно прописать его в настройках. Решение корявенькое, но работает. Ещё одна минорная проблема, которая может возникнуть, — различие в правах WSL/Windows. WSL-окружение видит все файлы Windows-окружения с правами 777, что, очевидно, нервирует SSH. Если вы указываете в качестве user.signingkey путь к ключу в окружении Windows, «умный» SSH наотрез отказывается работать. В качестве решения можно использовать непосредственное задание публичного ключа вместо указания пути к нему:
git config --global user.signingkey "ssh-ed25519 AAAAC3 user@test.lc"
Ну, теперь-то уж точно всё? Практически. Остался достаточно редкий кейс с использованием всякого рода удалённых виртуалок.
Удалённая работа
Собственно, с одной стороны, никто нам не помешает просто скопировать ключи на удалённую машину, «после чего решение сведется к предыдущему варианту», но без нужды разбрасываться закрытым ключом по различным машинам — ну очень такое себе.
На этот случай есть вполне типовое решение от проекта OpenSSH — “SSH agent forwarding”, позволяющее пробросить сокет через SSH-соединение. Для настройки нам необходимо создать файл ~/.ssh/config (Да-да, под Windows вполне работает) и прописать в нём разрешение пересылки агента для требуемого хоста или хостов:
Host 192.168.1.61
HostName haproxy.test.lc
User shaman@test.lc
ForwardAgent
И подключиться к нему через ssh с указанием ключа -A:
ssh -l shaman@test.lc 192.168.1.61 -A
shaman@test.lc@haproxy:~$ ssh-add -l
256 SHA256:... user@test.lc (ED25519)
На сим с настройкой клиентской части мы закончили (варианты использования gitbash ssh\putty+pagent и т. д. не рассматриваем как очевидно маргинальные) и можно задуматься и о централизованном управлении ключами в корпоративной среде - но это уже са-аа-авсем другая история...