Переход Git с SHA-1 на SHA-256 — не смена одной функции на другую, а перестройка форматов объектов, pack-индексов, протокола и всей экосистемы инструментов вокруг репозитория. Разбираем, как устроена механика перехода по спецификации hash-function-transition и по коду Git, и почему цена этого перехода может оказаться выше ожидаемой.
Почему SHA-256 и как выбирали
Спецификация формулирует четыре требования к новой хеш-функции. Первое — длина 256 бит: достаточно длинная, чтобы соответствовать распространённой практике безопасности, но не настолько, чтобы вредить производительности и использованию диска. Второе — широкое наличие качественных реализаций, например в OpenSSL и Apple CommonCrypto. Третье — соответствие нуждам Git: требуются устойчивость к коллизиям и ко второму прообразу, а устойчивость к удлинению сообщения не требуется. Четвёртое — скорость вычисления как тайбрейкер.
Среди кандидатов на замену SHA-1 были SHA-256, SHA-512/256, SHA-256x16, K12 и BLAKE2bp-256. В конце 2018 года проект выбрал SHA-256 в качестве преемника.
Какие атаки рассматривают и почему риск для репозитория ниже, чем кажется
Автор блога выделяет два типа атак на хеш-функции. коллизионная атакаатака, при которой злоумышленник намеренно создаёт два файла с одинаковым хешем — безобидный и вредоносный — и подменяет первый вторым, когда хеш совпадает работает так: злоумышленник генерирует две версии файла с одинаковым хешем, даёт людям безобидную, пока не получит доверие, а затем подменяет её вредоносной — Git не отличит их по хешу. атака второго прообразаатака, при которой злоумышленник видит существующий файл и создаёт второй файл, вредоносный по действию, но случайно совпадающий по хешу с оригиналом устроена иначе: злоумышленник видит файл, который хочет заменить, и создаёт второй файл, делающий что-то вредоносное и при этом случайно совпадающий по хешу с оригиналом, чтобы жертва вытянула его, не подозревая подмены.
Ключевое наблюдение автора: почти ни одна широко используемая хеш-функция не подвержена атаке второго прообраза, и даже MD5, считающийся полностью сломанным, эффективно иммунен к ней. Он делает допущение, что даже если SHA-1 тривиально ломается и создание второго прообраза возможно или даже дёшево, это не делает доступные векторы атаки лёгкими для эксплуатации. Реальные атаки на кодбазы происходят не через коллизии хешей, а через социальную инженерию — например, получение доступа к npm-пакету, что в миллиард раз проще и дешевле, чем брутфорс коллизии хеша.
Что спецификация прямо называет не-целями
В документе перечислены Non-Goals — то, что сознательно не входит в начальный дизайн. Добавление поддержки SHA-256 в Git-протокол описано как ценное и логичное следующее действие, но выходящее за рамки этого начального дизайна. Прозрачное повышение безопасности существующих SHA-1-подписанных объектов не предусмотрено. Смешивание объектов с разными хеш-функциями в одном репозитории не допускается. Использование перехода как повода исправить другие ошибки форматов и протоколов Git не планируется. Shallow-клоны и fetch в SHA-256-репозиторий не поддерживаются (это изменится, когда SHA-256 появится в Git-протоколе). Пропуск fetch некоторых подмодулей проекта в SHA-256-репозиторий тоже не поддерживается и также зависит от поддержки SHA-256 в протоколе.
Отклонённые альтернативы показывают, почему выбран именно такой путь. Переключение всех в один день отклонено: для больших проектов вроде ядра Linux это невыполнимо, а всем разработчикам, серверам и инструментам (непрерывная интеграция, код-ревью, баг-трекеры) пришлось бы перейти одновременно. Параллельное использование хеш-функций отклонено, потому что истории нельзя будет доверять в будущем без дополнительной работы, а нагрузка на сопровождение растёт с числом поддерживаемых хеш-функций — они никогда не исчезают и накапливаются. В предлагаемом варианте конвертированные объекты теряют все ссылки на SHA-1.
Альтернатива «Signed objects with multiple hashes» использовала один подписанный payload с обоими хешами. Её недостаток: проверка требует доступа к SHA-1-именам всех объектов, на которые ссылается подписанный объект, даже после завершения перехода. Поддержка подписанных объектов без SHA-1 усложняла дизайн, требуя поля «nohash sha1», подавляющего включение полей «hash sha1» в SHA-256-содержимое и подписанный payload. Альтернатива «Lazily populated translation table» отклонена, потому что отложенное построение таблицы существенно усложняет и замедляет push.
Формат репозитория и что увидит старый Git
Новый формат задаётся в конфигурации: в секции [core] устанавливается repositoryFormatVersionверсия формата репозитория, которую Git проверяет при открытии, а в секции [extensions] указываются objectFormatнастройка, задающая хеш-алгоритм, которым адресуются объекты репозитория и compatObjectFormatнастройка, задающая дополнительный хеш-алгоритм для совместимости со старыми клиентами. Сочетание версии 1 и заполнения extensions.* гарантирует, что все версии Git новее v0.99.9l завершатся с ошибкой вместо попытки работать с SHA-256-репозиторием.
Старый Git между v0.99.9l и v2.7.0 при попытке открыть такой репозиторий выдаёт fatal: Expected git repo version <= 0, found 1. После v2.7.0 сообщение меняется на fatal: unknown repository extensions found: objectformat compatobjectformat. Создать SHA-256-репозиторий можно командой git init --object-format=sha256.
Как устроен pack-индекс в SHA-256-репозитории
pack-индексфайл-спутник pack-файла, по которому Git быстро находит объект в упакованном виде, не распаковывая весь pack
Pack-индекс версии 3 в SHA-256-репозитории начинается с заголовка: 4-байтовая сигнатура '\377t0c', 4-байтовый номер версии 3, 4-байтовая длина секции заголовка, 4-байтовое число объектов и 4-байтовое число форматов объектов (2). Для каждого формата записываются 4-байтовый идентификатор, 4-байтовая длина укороченных имён и 8-байтовое смещение таблиц этого формата от начала файла; далее идёт 8-байтовое смещение к трейлеру.
Таблицы первого формата включают отсортированную таблицу укороченных имён, таблицу полных имён в порядке pack, таблицу 4-байтовых значений от порядка имён к порядку packпорядок, в котором объекты физически лежат в pack-файле; перестановка нужна, чтобы по имени объекта получить его место в pack, таблицу 4-байтовых CRC32 и таблицу 4-байтовых смещений. Смещения обычно 31-битовые, а большие кодируются как индекс в следующую таблицу со старшим битом. Таблица 8-байтовых смещений пуста для pack-файлов менее 2 GiB.
В pack-write.c функция need_large_offset возвращает 1, если смещение не влезает в 31 бит или превышает opts->off32_limit:
if ((offset >> 31) || (opts->off32_limit < offset))
return 1;write_idx_file выбирает версию индекса 2, если последнее смещение требует large offset, иначе opts->version. Для версий 2 и выше пишется заголовок с PACK_IDX_SIGNATURE и версией, затем 256-элементная таблица первого уровня для ускорения поиска.
Подписи объектов: gpgsig-sha256 и что реально проверяется
Спецификация добавляет в формат объекта commit новое поле gpgsig-sha256, аналогичное существующему gpgsig, чтобы подписывать коммиты без опоры на SHA-1. Подписываемые данные — SHA-256-содержимое объекта commit с удалёнными полями gpgsig и gpgsig-sha256.
Для тегов добавляются поля gpgsig и gpgsig-sha256: подпись внутри тела используется для текущего алгоритма хеширования, а заголовок — для другого алгоритма. Подписываемые данные тега — содержимое тега в текущем алгоритме с удалёнными полями gpgsig, gpgsig-sha256 и встроенной подписью, ограниченной -----BEGIN PGP SIGNATURE-----.
Важно понимать, что функции в object-file.c проверяют целостность объекта по его хешу, а не PGP-подпись. check_object_signature вычисляет хеш содержимого через hash_object_file и сравнивает его с переданным oid, возвращая -1 при несовпадении:
hash_object_file(algo, buf, size, type, &real_oid);
return !oideq(oid, &real_oid) ? -1 : 0;stream_object_signature формирует заголовок объекта, инициализирует хеш-контекст, обновляет его заголовком и читаемыми блоками потока, затем сравнивает итоговый oid с переданным. Обе функции проверяют целостность объекта по хешу, а не PGP-подпись или поля gpgsig/gpgsig-sha256.
vtable хеш-функций и «небезопасный» SHA-1
В hash.h определена структура git_hash_algo, описывающая алгоритм хеширования: имя алгоритма, четырёхбайтовый идентификатор версии для pack-индексов, длина хеша в байтах, длина в hex-символах, размер блока, указатели на функции инициализации, клонирования, обновления, финализации, а также указатели на OID пустого дерева, пустого блоба и нулевого OID.
Массив hash_algos[GIT_HASH_NALGOS] содержит конкретные реализации: нулевой элемент с name = NULL и функциями git_hash_unknown_*, элемент "sha1" с unsafe = &sha1_unsafe_algo, и элемент "sha256" без поля unsafe. Функция unsafe_hash_algo возвращает algop->unsafe, если он задан, иначе сам algop:
/* If we have a faster "unsafe" implementation, use that. */
if (algop->unsafe)
return algop->unsafe;
/* Otherwise use the default one. */
return algop;Именно поэтому SHA-1 по умолчанию использует небезопасную реализацию: в записи для "sha1" задано .unsafe = &sha1_unsafe_algo, а git_hash_sha1_init_unsafe устанавливает ctx->algop = unsafe_hash_algo(&hash_algos[GIT_HASH_SHA1]) и вызывает git_SHA1_Init_unsafe. Аналогично git_hash_sha256_init вызывает unsafe_hash_algo для SHA-256, но у sha256 поле unsafe не задано, поэтому возвращается сам алгоритм.
Жизненный цикл хеш-контекста
git_hash_init инициализирует контекст, вызывая init_fn алгоритма и помечая контекст активным. git_hash_clone копирует состояние из src в dst, но только если оба контекста активны, иначе BUG. git_hash_update добавляет данные в активный контекст. git_hash_final и git_hash_final_oid завершают хеширование, записывая результат в буфер или object_id, и деактивируют контекст. git_hash_discard освобождает ресурсы, если контекст активен, и деактивирует его.
Варианты *_unsafe используют альтернативные реализации (например, git_SHA1_Init_unsafe) и выбираются через unsafe_hash_algo. Функции git_hash_unknown_* вызывают BUG с сообщением о попытке работы с неизвестным хешем.
Комментарий «This currently does nothing, so the compiler should optimize it out» относится к memset в git_hash_sha256_final_oid, который обнуляет байты после GIT_SHA256_RAWSZ до GIT_MAX_RAWSZ, но так как GIT_MAX_RAWSZ равен GIT_SHA256_RAWSZ, операция ничего не делает.
Поиск алгоритма по имени, id, длине и указателю
Поиск алгоритма по имени, идентификатору формата или длине хеша даёт номер известного алгоритма, а для неизвестного значения сообщает, что алгоритм не распознан. Поиск по указателю на запись алгоритма работает так же: он находит соответствующую запись среди известных и в противном случае сообщает о нераспознанном алгоритме. Поиск по номеру алгоритма даёт запись алгоритма, если номер попадает в известный диапазон; для номера вне диапазона запись не находится.
Инварианты длины хеша в object_id
object_id хранит хеш в буфере фиксированной максимальной длины GIT_MAX_RAWSZ и поле algo — номер алгоритма. hashcmp/hasheq сравнивают только первые rawsz байт, причём реализация различает лишь два случая: если rawsz == GIT_MAX_RAWSZ, сравниваются все GIT_MAX_RAWSZ байт, иначе — GIT_SHA1_RAWSZ байт. hashcpy копирует ровно algop->rawsz байт, а hashclr обнуляет ровно algop->rawsz байт, то есть эти две функции зависят от длины алгоритма.
oidcmp/oideq, напротив, всегда сравнивают GIT_MAX_RAWSZ байт, а oidcpy всегда копирует GIT_MAX_RAWSZ байт и дополнительно переносит поле algo из src в dst. oid_set_algo не трогает байты хеша, а только записывает в oid->algo номер алгоритма, полученный через hash_algo_by_ptr. oid_common_prefix_hexlen берёт rawsz из hash_algos[a->algo] и сравнивает байты до первого различия, возвращая i*2, если различаются старшие полубайты, i*2+1, если только младшие, и rawsz*2 при полном совпадении.
Таблица соответствия SHA-256 ↔ SHA-1
Двунаправленная таблица соответствия хранится в файле loose-object-idx, формат которого — повторяющиеся строки «sha256-name SP sha1-name LF», где имена объектов записаны в шестнадцатеричном виде. Этот файл не отсортирован.
Ленивое пополнение выполняется при добавлении нового loose-объекта: сначала объект пишется во временный файл, затем через open с O_CREAT | O_EXCL захватывается блокировка loose-object-idx.lock, объект переименовывается на место, после чего loose-object-idx открывается с O_APPEND и в него дописывается новая строка, и наконец блокировка снимается удалением loose-object-idx.lock.
При удалении записей (например, в git pack-refs или git-prune) блокировка захватывается так же, новое содержимое пишется в loose-object-idx.lock, удаляются соответствующие loose-объекты, и файл переименовывается поверх loose-object-idx, что снимает блокировку.
Поиск при преобразовании SHA-1 в SHA-256 идёт сначала по idx-файлам, а затем по loose-object-idx: строки читаются до совпадения, что занимает O(number of loose objects) времени, поэтому число loose-объектов нужно держать низким.
Fetch и push между SHA-1 и SHA-256 клиентами
При fetch от сервера на SHA-1 клиент SHA-256 переводит имена объектов: SHA-1-имена из ref advertisement, присутствующие на клиенте, могут быть переведены в SHA-256 и найдены как локальные объекты через таблицу трансляции. В ходе переговоров локально сгенерированные «have» конвертируются в SHA-1 перед отправкой серверу, а упомянутые сервером SHA-1 конвертируются в SHA-256 при локальном поиске.
После переговоров сервер присылает packfile, который клиент конвертирует в SHA-256 в несколько шагов: index-pack распаковывает каждый объект и вычисляет его SHA-1, topological sort обходит объекты от «want» и выдаёт список в обратном топологическом порядке без blob-ов, затем объекты конвертируются в SHA-256 с записью соответствия SHA-1↔SHA-256, выполняется сортировка и очистка.
Push проще, потому что объекты, на которые ссылаются отправляемые, уже есть в таблице трансляции. Спецификация помечает детали этого процесса как «likely to change»: потребуются эксперименты, чтобы добиться хорошей производительности.
Неоднозначность имён на командной строке
Спецификация решает неоднозначность имён объектов тем, что SHA-256 и SHA-1 имеют разную длину, и вводит четыре режима работы, задаваемых в конфигурации. Режим dark launchрежим, в котором имена на входе и на выходе трактуются как SHA-1, хотя объекты в репозитории хранятся по SHA-256. Режим early transitionрежим, в котором на входе допустимы имена и в SHA-1, и в SHA-256, а на выходе выдаются имена в SHA-1. Режим late transitionрежим, в котором на входе допустимы имена и в SHA-1, и в SHA-256, а на выходе выдаются имена в SHA-256. Режим post-transitionрежим, в котором имена и на входе, и на выходе — в SHA-256.
Пользователь может явно указать формат для конкретного ревизионного спецификатора и для вывода, переопределяя режим:
git --output-format=sha1 log abac87a^{sha1}..f787cac^{sha256}Разбор имени без указания алгоритма устроен так: перебираются алгоритмы от самого длинного к самому короткому, и берётся первый, для которого хеш удалось разобрать; если ни один не подошёл, алгоритм считается неизвестным. При успешном разборе конец строки определяется по длине распознанного алгоритма. Это соответствует идее спецификации о различении форматов по длине имени.
Пограничные случаи: рост loose-object-idx и gc
Быстрые поиски в loose-object-idx требуют, чтобы число loose-объектов не становилось слишком большим. Поиск занимает время, пропорциональное числу loose-объектов, поэтому для сохранения производительности нужно удерживать их число малым.
git gc --auto важен, потому что он ждёт накопления 50 pack-файлов перед объединением pack-файлов. Более агрессивная упаковка loose-объектов может слишком быстро увеличить число pack-файлов, и это можно смягчить стратегией, подобной экспоненциальному скрипту сборки мусора Мартина Фика. Кроме того, git gc сейчас выбрасывает недостижимые объекты из pack-файлов в loose-объекты, что ведёт к взрыву числа loose-объектов и расходу диска: объекты в дельта-форме заменяются независимыми loose-объектами.
Поле PSRC в pack-индексе
В спецификации pack-индекса поле PSRC — это PSRCдополнительная пара ключ/значение в заголовке индекса, которая записывает, откуда взялся pack-файл, где ключ имеет размер 4 байта и значение 4 байта; поддерживается только ключ 'PSRC', а все прочие ключи зарезервированы и должны игнорироваться читателями. Значение PSRC указывает происхождение pack-файла:
- 1 (
PACK_SOURCE_RECEIVE) — pack, полученный по сети; - 2 (
PACK_SOURCE_AUTO) — pack, созданный лёгкой операциейgc --auto; - 3 (
PACK_SOURCE_GC) — pack, созданный полным gc; - 4 (
PACK_SOURCE_UNREACHABLE_GARBAGE) — потенциальный мусор, обнаруженный gc; - 5 (
PACK_SOURCE_INSERT) — локально созданные объекты, записанные напрямую в pack-файл, например изgit add ..
Эта информация полезна для отладки и для gc --auto, чтобы выбирать, какие pack-файлы объединять. При gc недостижимые объекты должны перемещаться в новый pack-файл, помеченный как UNREACHABLE_GARBAGE через поле PSRC. Чтобы избежать гонки при записи новых объектов, ссылающихся на удаляемый объект, код, записывающий новые объекты, должен копировать объекты из UNREACHABLE_GARBAGE-паков, на которые они ссылаются, в новые pack-файлы без пометки UNREACHABLE_GARBAGE (или в loose-объекты), после чего UNREACHABLE_GARBAGE можно безопасно удалять, если время их создания (по mtime файла) достаточно давнее.
Чтобы избежать разрастания числа UNREACHABLE_GARBAGE-паков, их можно объединять: gc.garbageTtlпараметр конфигурации, задающий срок жизни мусорных pack-файлов, по которому определяется, какие из них можно объединять — если он больше одного дня, паки, созданные в один календарный день UTC, можно объединять, а если меньше одного дня, календарный день делится на интервалы длительностью в одну треть ttl, и паки внутри одного интервала можно объединять.
Push на хост без поддержки формата
При попытке push SHA-256-репозитория на хост без поддержки нового формата git выводит сообщение fatal: the receiving end does not support this repository's hash algorithm. В приведённых исходниках odb.c и object-file.c проверок, приводящих именно к этому сообщению, нет: в odb.c есть odb_assert_oid_type, который проверяет тип объекта и при несоответствии вызывает die с сообщениями "%s is not a valid object" или "%s is not a valid '%s' object"; в object-file.c функция hash_format_check_report сообщает "object fails fsck: %s", а index_mem при INDEX_FORMAT_CHECK вызывает die("refusing to create malformed object"). Ни одна из этих проверок не связана с поддержкой хеш-алгоритма принимающей стороной.
Что ломается в экосистеме
Хосты (forges) не могут смешивать проекты: каждый проект окажется в одной из двух категорий, и смешивать их нельзя. При создании репозитория на хосте нужно знать, с какой версией git выполнялся git init, и выбрать правильный формат на сервере.
Внешние библиотеки и инструменты страдают сильнее всего: для библиотек это особенно проблематично, потому что подмодули работают только с проектами того же типа. Все внутренние и прочие инструменты, ожидающие 40 символов хеша, сломаются или потребуют обновления, чтобы угадывать или определять формат хеша.
Причина, по которой автор считает, что «you may be out of luck for some operations», в том, что большинство библиотек экосистемы Git — это переписанные с нуля реализации с нулевой или частичной поддержкой нового формата. Поэтому все скрипты и инструменты, которые не вызывают бинарник Git через fork-exec, будут в той или иной степени ломаться на таких репозиториях.
Аргумент про дедлайн NIST и «второй независимый подписанный хеш»
Автор связывает переход на SHA-256 с дедлайном NIST 2030 через аргумент, что этот дедлайн касается использования SHA-1 «for applying cryptographic protection», а не самого существования SHA-1 в стеке. Он утверждает, что если каждая подпись также покрывает SHA-256-хеш содержимого, то SHA-1 больше ничего не защищает и становится просто ключом адресации содержимого.
По его словам, «второй независимый подписанный хеш» решал бы задачу дешевле, потому что независимая подпись содержимого дерева стоит дороже только в момент подписи, а проверка недорога. В худшем случае на 35 ГБ рабочего дерева из 2,1 млн файлов инструмент сгенерировал контрольную сумму (все файлы основного дерева, все файлы подмодулей) за 5 секунд (M5 Mac, многопоточно). Дерево Linux занимает 257 мс для 1,5 ГБ, проект Git — 17 мс.
Он также отмечает, что такой подход можно бэкфиллить, навешивая подписанные контрольные суммы на прошлые коммиты. Кроме того, это технически безопаснее, чем один SHA-256, так как для проблемы нужно получить коллизию в обоих алгоритмах, и позволяет перестать доверять содержимому через SHA-1, не выбрасывая SHA-1 и не ломая экосистему.
Что из этого следует на практике
Переход на SHA-256 — это не замена хеш-функции, а смена форматов репозитория, pack-индексов, подписей и протокола. Репозиторий становится либо SHA-1, либо SHA-256, и смешивать их нельзя, а хосты вынуждены держать проекты в двух непересекающихся категориях.
Инструменты, ожидающие 40-символьный хеш, ломаются или требуют обновления, а библиотеки экосистемы Git с нулевой или частичной поддержкой нового формата означают, что любой инструмент, не вызывающий бинарник Git, может не справиться с частью операций.
Производительность перехода зависит от числа loose-объектов: поиск в loose-object-idx занимает O(number of loose objects) времени, поэтому gc --auto с порогом 50 pack-файлов и стратегии агрессивной упаковки становятся критичными для поддержания скорости. При этом git gc сейчас выбрасывает недостижимые объекты в loose-объекты, что ведёт к взрыву их числа и расходу диска.
С точки зрения реального риска для репозитория автор считает, что атаки на кодбазы происходят не через коллизии хешей, а через социальную инженерию, и что альтернатива с «вторым независимым подписанным хешем» решала бы задачу дешевле и безопаснее, не ломая экосистему.
Где смотреть в коде
- hash.c: git_hash_init
- hash.h: hash_algo_by_ptr
- hash.c: git_hash_sha1_init_unsafe
- hash.c: null_oid
- hex.c: get_hash_hex_algop
- object-file.c: check_and_freshen_file
- object-file.c: hash_format_check_report
- hash.c: oid_common_prefix_hexlen