Design by Contract Мейера не взлетел в 1986 из-за двойной работы. AI-агент убирает вторую половину. Я построил PKI-систему с аппаратным TRNG, формальными контрактами на криптографию и открытым репозиторием, чтобы это проверить.
1. Проблема: AI ломает быстрее, чем люди чинят
В марте 2026-го утекли внутренние документы Anthropic о модели Claude Mythos. Через пару недель они сами её анонсировали. Mythos нашёл 0-day в OpenBSD, который сидел в коде двадцать семь лет. Двадцать семь. В FFmpeg обнаружился похожий, шестнадцатилетней давности, а Linux kernel тоже зацепило. На поиск бага в OpenBSD ушло меньше пятидесяти долларов compute. FFmpeg до этого сканировали миллионы раз автоматическими инструментами, и ничего.
Ладно, допустим, это впечатляет. Но что с этим делать? Запретить AI искать баги не получится. Значит, нужно строить системы так, чтобы логические ошибки не доживали до продакшна.
Стандартный ответ: переписывать на Rust. Для memory corruption это и правда работает (хотя и не для всего legacy), ownership model не даёт скомпилировать use-after-free и buffer overflow, и баги в OpenBSD с FFmpeg были как раз из этой категории. Но вот в чём штука. Есть целый класс ошибок, которые ни один язык не видит, и это логические ошибки. Выбрал неправильный padding для RSA, компилятор молчит. Забыл проверить revocation сертификата или передал сырую entropy в DRBG без проверки качества, тесты зелёные. Потому что никто не написал тест на это.
Тесты вообще проверяют ровно то, что программист додумался проверить. Уязвимость в OpenBSD двадцать семь лет проходила все тесты. Все до единого.
Нужен подход, который работает не на уровне синтаксиса, а на уровне архитектурных гарантий. Который фиксирует “что система обязана делать” до первой строки кода. Не “как реализовать”, а “что гарантировать”. Разница принципиальная: реализацию можно переписать, а гарантия либо выполняется, либо нет.
Исторически, такой подход уже придумали. В 1986 году. Автор: Бертран Мейер. Design by Contract. И есть конкретная причина, почему вы скорее всего о нём не слышали.
Дальше: почему DbC не взлетел, что меняет AI-агент, и как это выглядит на реальной PKI-системе с открытым репозиторием.
2. DbC Мейера: почему не взлетело
Идея простая. Каждый модуль явно декларирует три вещи: что ожидает на входе (предусловия, PRE), что гарантирует на выходе (постусловия, POST), что никогда не нарушит (инварианты, INV). Например, функция деления: PRE: делитель ≠ 0, POST: результат × делитель == делимое. Просто, но формально.
Строго. Формально. И в mainstream не прижилось, что, в общем, предсказуемо.
Причина банальная: двойная работа. На практике это выглядит так: сначала пишешь контракт. Потом пишешь код. Потом пишешь тесты. Контракт это дополнительная нагрузка, которая не даёт немедленной отдачи. Когда горит дедлайн, контракты выбрасывают первыми.
В safety-critical областях DbC выжил. Ada/SPARK в авиации, ISO 26262 в automotive. По сути те же PRE/POST/INV, только под другими именами и с другим бюджетом. Там цена ошибки такая, что двойная работа оправдана. В обычной разработке: нет. И все это понимали.
Eiffel, язык Мейера со встроенными контрактами, остался нишевым. Microsoft Code Contracts для C#: заброшен. Java добавила assert в 1.4, но без обязательности. D language встроил контракты в синтаксис, и тоже не стал мейнстримом. Идея расползлась по assert’ам, аннотациям, типам. Но формальные PRE/POST/INV на уровне архитектуры так и не стали нормой.
Мейер (1986) |
Что получилось |
|
|---|---|---|
Контракт |
Пишет человек |
Пишет человек |
Код |
Пишет человек |
Пишет человек |
Проверка |
Компилятор Eiffel |
Человек (тесты) |
Итог |
Двойная работа |
Контракты выбрасывают |
40 лет DbC ждал момента, когда вторую половину работы можно будет отдать кому-то другому. И похоже, этот момент наступил.
Кстати, я не один это заметил. В последние пару лет пошли академические работы на стыке DbC и LLM. “A DbC Inspired Neurosymbolic Layer for Trustworthy Agent Design” (arxiv:2508.03665): контрактный слой между LLM и выходом. “Formal Specification and Runtime Enforcement for Reliable Autonomous AI Agents” (arxiv:2602.22302): ABC-контракты для AI-агентов. Fun2spec (openreview): LLM генерирует контракты для миллионов строк C/C++. В блогах пишут про “prompt contracts” и “files as contracts”.
Но все эти работы: либо теория без production proof, либо промт-инженерия без формальных PRE/POST/INV. Связку “DbC + AI-агент + криптография + открытый репозиторий” пока не показал никто. Мне было интересно попробовать.
3. Что меняет AI-агент
Человек пишет контракт. Агент берёт его и пишет код, тесты, проверки. А человек потом смотрит на 10 строк контракта вместо тысячи строк реализации и решает, всё ли правильно.
Собственно, всё. Агент берёт на себя реализацию и генерацию тестов, а двойная работа, которая убила DbC, исчезает. Контракт остаётся единственной задачей человека.
Мейер (1986) |
С AI-агентом |
|
|---|---|---|
Контракт |
Человек |
Человек |
Код |
Человек |
Агент |
Проверка |
Компилятор Eiffel |
Агент + тесты + автоверификация |
Почему не работало |
Двойная работа |
Делегировано |
На практике один цикл выглядит так. Пишу инвариант: “PKCS#1 v1.5 == ЗАПРЕЩЕНО”. Агент генерирует crypto_engine.py с RSA-PSS. Параллельно генерирует contract-тест, который пытается вызвать PKCS#1 v1.5 и проверяет, что система его отвергает. Тест прошёл, контракт соблюдён. Упал: агент правит реализацию, не контракт. Контракт: источник истины. Его не трогают.
Ошибка в контракте тоже возможна, конечно. Но в типичном случае проверить 10 строк декларации намерения, особенно если сверяешь с NIST или RFC, проще, чем искать логическую ошибку в 1000 строк реализации. Контракт это точка фокуса. Вместо вопроса “правильно ли написан код?” ты отвечаешь на вопрос “правильно ли я описал, что хочу?”. Второй вопрос проще.
4. Proof: PKI с аппаратным TRNG
Я построил PKI-систему с аппаратным генератором случайных чисел, и хочу рассказать как это было. Цель кейса: показать, что DbC + AI-агент работает не в теории, а на реальном fullstack embedded проекте с криптографией. От и до. На входе тепловой шум с ADC микроконтроллера. Он идёт в DRBG по NIST SP 800-90A, оттуда в генерацию ключей, потом CA выпускает сертификат X.509. Разработка шла на Windows, продакшн крутится на Linux ARM64 (RK3328, Cortex-A53), а firmware живёт на STM32.
Стек выглядит так: STM32G431 снимает тепловой шум с внутреннего ADC и отдаёт сырую энтропию через USB HID. На хосте (RK3328) Python-демон читает эти байты, прогоняет через health check по NIST 800-90B, и скармливает в HMAC-DRBG. Дальше CryptoEngine берёт выход DRBG и генерирует RSA/ECDSA ключи. KeyStorage шифрует приватные ключи AES-256-GCM и кладёт на диск. CA-сервис подписывает сертификаты. Пять модулей, каждый со своим контрактом на стыке.
Вот цифры. Главное здесь: две CRITICAL уязвимости найдены именно через контрактный подход, до продакшна.
Метрика |
Значение |
|---|---|
Коммитов |
131 |
Дни активной работы |
11 (из 17 календарных) |
Автор |
1 человек |
Тестов |
62 contract + 15 HW + unit |
MCU борды |
3 (STM32G474, STM32G431, STM32H750) |
Целевая платформа |
RK3328 ARM64 (Cortex-A53) |
Стоимость железа |
~$129 |
Стоимость AI |
1780₽ (~$16) за 30 сессий с трекингом |
Репозиторий открыт: github.com/vasilievsv/hw.pki-on-box
SELinux в enforcing, eBPF-фильтры, systemd sandboxing, всё как положено. По firmware: 12 пунктов криптоаудита по NIST 800-90B, все 12 закрыты, открытых ноль.
Разработка шла в несколько фаз. Сначала аудит прототипа и написание контрактов на каждый модуль. Потом реализация core: TRNG, DRBG, CryptoEngine, KeyStorage, CA-сервис, CRL. Параллельно contract-тесты, сначала на моках (62 теста), потом на реальном железе (15 HW-тестов). Отдельная фаза на firmware: три борды STM32, новая прошивка для G431, 12 пунктов криптоаудита. И финал: деплой на RK3328, пересборка ядра 5.10 с SELinux и eBPF, smoke-тесты на живой железке. Каждая фаза это отдельная сессия с агентом, со своей ролью и контекстом. Роли важны: тестировщик не пишет код, кодер не меняет контракты, архитектор не запускает тесты. Это не бюрократия, это способ не дать агенту уйти в сторону. Когда у сессии есть роль и контракт, агент работает в рамках. Без рамок он начинает “помогать” и ломает то, что уже работает.
5. Два контракта на двух стыках
Вся криптография в pki-on-box держится на двух контрактах, и каждый из них закрывает один архитектурный стык. Стыки выбраны не случайно: именно на границах модулей возникают логические ошибки, которые внутри модуля не видны. DRBG отдаёт байты, CryptoEngine их использует. Если на этом стыке не зафиксировать, какой padding допустим, ошибка неизбежна. Ниже упрощённые версии для иллюстрации, полные контракты в репозитории.
Первый, генерация ключей:
contract: key_generation PRE: - DRBG.seeded == true - DRBG.reseed_counter < max_requests - algorithm ∈ {RSA-2048, RSA-4096, ECDSA-P384, Ed25519} POST: - private_key.encrypted(AES-256-GCM) - nonce.unique() - public_key = derive(private_key) INV: - padding == RSA-PSS (подпись) | RSA-OAEP (шифрование) - PKCS#1 v1.5 == ЗАПРЕЩЕНО
Почему PKCS#1 v1.5 запрещён. Не каприз. Атака Блейхенбахера (1998) использует padding oracle в PKCS#1 v1.5 для восстановления plaintext. Это не теоретическая атака, её использовали на реальных системах. RSA-PSS и RSA-OAEP спроектированы так, чтобы этот класс атак был невозможен. Инвариант в контракте закрывает это на уровне архитектуры. До того, как кто-то случайно выберет v1.5 “потому что в примере на StackOverflow было так”.
Второй контракт, выпуск сертификата:
contract: certificate_issuance PRE: - issuer_ca.valid() && !issuer_ca.revoked() - csr.signature.verify() == true POST: - cert.serial.unique() - cert.signature.verify(issuer_ca.public_key) == true - cert.extensions.key_usage.set() INV: - cert_chain.depth <= max_path_length - root_ca.offline == true (air-gapped)
Автоматическая проверка через contract-тесты нашла две CRITICAL проблемы: неправильный padding (PKCS#1 v1.5 вместо RSA-PSS) и неправильный AES mode.
Почему обычные тесты это пропускают? Обе логические ошибки, и ни компилятор, ни линтер тут бесполезны. Обычные unit-тесты, может, и поймали бы, но для этого кто-то должен был догадаться их написать. А contract-тесты генерируются из формальной спецификации. Им не нужно, чтобы программист о чём-то догадывался.
Реализация: test_crypto_engine_contract.py
Вот как выглядит один из инвариантных тестов в коде:
class TestCryptoEngineInvariants: def test_rejects_pkcs1v15_padding(self, crypto): priv, pub = crypto.generate_rsa_keypair(bits=2048) data = b"invariant check" sig = crypto.sign_data(priv, data) # PSS обязан работать pub.verify(sig, data, padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.AUTO ), hashes.SHA256()) # PKCS1v15 обязан сломаться with pytest.raises(Exception): pub.verify(sig, data, padding.PKCS1v15(), hashes.SHA256())
Тест не проверяет “работает ли подпись”. Он проверяет инвариант из контракта: система подписывает RSA-PSS, и PKCS#1 v1.5 обязан сломаться при верификации. Если кто-то случайно переключит padding, этот тест упадёт. Не потому что программист догадался, а потому что контракт потребовал.
6. Как это выглядит в работе
Процесс не линейный, но если упрощать, примерно так.
Сначала я формулирую контракт, PRE/POST/INV на стыке модулей. Для меня это самая сложная часть, потому что надо заставить себя думать не про код, а про гарантии: что система обязана делать, что ей запрещено.
На практике это выглядит так. Открываю новую сессию, указываю роль (coder, tester, architect), загружаю контекст предыдущей работы. Пишу контракт в yaml: PRE, POST, INV. Иногда контракт рождается за пять минут, если стык понятный. Иногда сижу полчаса, потому что не могу сформулировать инвариант. Это нормально. Если не можешь записать гарантию в одну строку, значит ещё не понимаешь, что именно система должна делать.
Дальше агент берёт этот контракт и генерирует реализацию, а параллельно ещё и contract-тесты, каждый из которых проверяет конкретное postcondition или пытается нарушить инвариант.
Тест упал, агент правит реализацию. Контракт не трогает. Контракт меняю только я, и только если понимаю, что сам ошибся в спецификации. Нарушение контракта это не warning и не TODO, это полная остановка. Код не мержится, пока все postconditions не зелёные.
Конкретный пример. В контракте key_generation стоит INV: padding == RSA-PSS. Агент сгенерировал CryptoEngine, и первая версия sign_data() использовала PKCS1v15. Contract-тест test_rejects_pkcs1v15_padding сразу поймал нарушение: верификация через PSS упала, потому что подпись была создана с другим padding. Агент переписал sign_data() на PSS, тесты позеленели. Весь цикл: контракт уже был, тест уже был, человек не участвовал. Я увидел зелёный прогон в логе и пошёл дальше.
На pki-on-box весь этот цикл занял 52 сессии с агентом (сессия = один чат с одной ролью и задачей), итого 1780₽ на compute. Embedded, DevOps и крипто одновременно, один человек. Без агента и контрактов я бы закладывал 60+ часов, и это оптимистично. Три домена (firmware, backend, infra), три операционки (Windows для разработки, Linux ARM64 для продакшна, bare metal для MCU). Контракты формализуют ожидания заранее, и агент реализует их без отклонений. Не надо объяснять ему по второму кругу, почему PKCS#1 v1.5 нельзя. Это записано в INV.
7. Ограничения и честные оговорки
Работает не с любой моделью. Модель должна удерживать формальные ограничения на протяжении длинной сессии и не забывать инварианты на пятидесятом сообщении в чате. Frontier-модели с этим справляются. Локальные на 7B параметров пока не тянут, но это вопрос времени.
Контракт тоже можно написать неправильно, и это важно понимать. Если в INV забыть запретить CBC mode для AES и оставить только GCM, агент послушно реализует CBC, и формально контракт будет соблюдён. Контракт не заменяет экспертизу. Он формализует то, что ты уже знаешь. Если не знаешь, формализовать нечего. На pki-on-box я дважды ловил себя на том, что контракт неполный: один раз забыл про AES mode (поймал на ревью), второй раз не указал минимальную длину entropy seed для DRBG. Оба раза фикс контракта занял одну строку, но без него агент бы генерировал формально корректный, но уязвимый код.
DbC не серебряная пуля. Он закрывает логические ошибки на архитектурных стыках, и только. Memory corruption это к Rust, race conditions к модели памяти, supply chain к совсем другим инструментам. И ещё: контракты работают на уровне модулей, не на уровне отдельных функций. Писать PRE/POST на каждый геттер бессмысленно. Контракт имеет смысл там, где есть архитектурный стык и цена ошибки высокая.
Заключение
Мейер придумал Design by Contract сорок лет назад. Идея была правильной, но момент не тот, и двойная работа убивала adoption.
Сейчас, в 2026, AI забирает вторую половину этой работы. Человек формулирует контракт, а агент реализует и проверяет. То, что раньше было “слишком дорого”, превратилось в “единственное, что нужно делать”.
У меня получилось 131 коммит за 11 дней, $129 на железо, 1780₽ на compute. И две CRITICAL, которые нашлись именно через контрактный подход, до продакшна.
Mythos показал, что AI ломает быстрее, чем люди чинят. От логических ошибок язык не спасает, а тесты проверяют только то, о чём додумался программист. Контракты работают иначе: они фиксируют то, что система обязана гарантировать. Додумался кто-то написать тест или нет.
Сорок лет назад Мейер предложил записывать эти гарантии явно. Тогда это стоило слишком дорого. Сейчас вторую половину работы берёт на себя агент, а контракт остаётся единственным артефактом, за который отвечает человек. Десять строк yaml вместо тысячи строк кода.
Если у вас в проекте есть криптография, платёжная логика, или любой другой код где ошибка в логике опаснее ошибки в памяти, попробуйте написать один контракт на самый критичный стык. PRE/POST/INV, десять строк. Дайте агенту реализовать и сгенерировать тесты. Посмотрите, что он найдёт.
Ссылки:
Meyer B. “Object-Oriented Software Construction” (1988, 2nd ed. 1997)
Meyer B. “Applying Design by Contract” (IEEE Computer, 1992)
Bleichenbacher D. “Chosen Ciphertext Attacks Against Protocols Based on the RSA Encryption Standard PKCS#1” (CRYPTO 1998)
NIST SP 800-90A: Recommendation for Random Number Generation Using Deterministic Random Bit Generators
NIST SP 800-90B: Recommendation for the Entropy Sources Used for Random Bit Generation
“A DbC Inspired Neurosymbolic Layer for Trustworthy Agent Design” arxiv:2508.03665
“Formal Specification and Runtime Enforcement for Reliable Autonomous AI Agents” arxiv:2602.22302
Fun2spec: Code Contract Synthesis At Scale openreview
Contract-тесты: test_crypto_engine_contract.py
pki-on-box: github.com/vasilievsv/hw.pki-on-box
Комментарии (2)

Dhwtj
19.04.2026 13:25Про пример кода на питон
тест "PSS != PKCS1v15" это тавтология про библиотеку, не про твой контракт.
Надо было проверить
Инвариант "мы подписываем PSS" проверяется наоборот:
взять подпись от sign_data
верифицировать её как PSS - должно пройти
верифицировать её как PKCS1v15 - должно упасть
Это пример где тестом пытаются починить то, что должно быть в типах. Если бы
sign_dataвозвращалаSignature<RsaPss>, а API верификации не принималPkcs1v15, такой тест был бы не нужен. Тест тут - костыль вместоmake illegal states impossibleКонкретно для питон лучше что-то такое
# mycrypto.py - единственная точка входа в криптографию from dataclasses import dataclass from typing import NewType from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.hazmat.primitives import hashes, serialization from cryptography.exceptions import InvalidSignature # --- доменные типы: снаружи сырых bytes нет --- @dataclass(frozen=True) class SigningKey: _key: rsa.RSAPrivateKey @dataclass(frozen=True) class VerifyingKey: _key: rsa.RSAPublicKey Signature = NewType("Signature", bytes) # не перепутать с обычными bytes # --- политика захардкожена: только RSA-PSS + SHA-256 --- _PSS = padding.PSS( mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH, ) _HASH = hashes.SHA256() def generate_keypair() -> tuple[SigningKey, VerifyingKey]: sk = rsa.generate_private_key(public_exponent=65537, key_size=3072) return SigningKey(sk), VerifyingKey(sk.public_key()) def sign(key: SigningKey, data: bytes) -> Signature: return Signature(key._key.sign(data, _PSS, _HASH)) def verify(key: VerifyingKey, sig: Signature, data: bytes) -> None: # бросает InvalidSignature если не PSS/не та подпись key._key.verify(sig, data, _PSS, _HASH) # --- тест политики (а не математики) --- # test_mycrypto.py import pytest import mycrypto from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import hashes from cryptography.exceptions import InvalidSignature def test_facade_signs_with_pss_and_rejects_pkcs1v15(): sk, vk = mycrypto.generate_keypair() data = b"payload" # 1. Наша подпись верифицируется как PSS - политика соблюдена sig = mycrypto.sign(sk, data) mycrypto.verify(vk, sig, data) # 2. Подпись, сделанная PKCS1v15 напрямую, отвергается нашим verify legacy_sig = sk._key.sign(data, padding.PKCS1v15(), hashes.SHA256()) with pytest.raises(InvalidSignature): mycrypto.verify(vk, legacy_sig, data)- прикладной код знает только SigningKey, VerifyingKey, Signature, sign, verify
- выбора padding/hash снаружи нет вообще
- тест проверяет политику фасада: PSS принимается, PKCS1v15 отвергается даже когда подпись валидна математически
И про ваш тест
Про "отвергает PKCS1v15" ваш тест молчит - потому что в него никто не подсовывает настоящую PKCS1v15-подпись
Dhwtj
Пардон, в криптографии не разбираюсь. Но знаю принципы make illegal states impossible, parse don’t validate. Поэтому ответ сильно обработанный нейросетью
Важно разделить 2 случая:
В коде зашито - ловится compile time. Пришло снаружи - ловится на границе один раз, потом внутри уже живут только безопасные типы. Это и есть parse, don’t validate.
Что хорошо ложится на make illegal states impossible.
На входе из сети/конфига/БД можно парсить сразу в разрешённые типы:
Алгоритм подписи
снаружи: “rsa-pss” / “rsa-pkcs1v15”
внутри: AllowedSigAlg
pkcs1v15 просто не парсится
Режим шифрования
снаружи: “aes-256-gcm” / “aes-cbc”
внутри: AllowedAead cbc отбрасывается на входе
Ключи
не Vec, а PublicKey, AeadKey при парсинге проверяются формат, длина, алгоритм, иногда usage
CSR / сертификаты / JWT
RawCsr -> VerifiedCsr
RawJwt -> VerifiedJwt
дальше нельзя случайно использовать непроверенный объект
Nonce/IV длина
Nonce12 вместо просто &[u8], неверная длина отсекается сразу
Cipher suite / версия протокола
NegotiatedStrongSuite
слабые наборы не проходят парсинг/согласование
Что можно поймать только частично
Срок действия сертификата
можно проверить на входе относительно now но через 5 минут факт может стать ложным значит это не “вечный” инвариант типа без привязки ко времени
Уникальность nonce
длину поймать легко, уникальность можно частично зашить в stateful API, но после рестарта/в кластере это уже не только типы
Что так не ловится
Это уже не “форма данных”, а “факт о мире”:
качество RNG, side-channel, отозван ли сертификат прямо сейчас, компрометация ключа, баг в криптобиблиотеке, downgrade, если протокол плохо спроектирован, повтор nonce между машинами/после crash recovery
Практически Самый полезный паттерн такой:
rust
// снаружи
let sig_alg: AllowedSigAlg = parse_sig_alg(input.sig_alg)?;
let aead: AllowedAead = parse_aead(input.aead)?;
let csr: VerifiedCsr = parse_and_verify_csr(input.csr)?;
// внутри
issue(sig_alg, csr);
encrypt(aead, key, plaintext);
То есть:
сырой внешний ввод -> один раз парсим и проверяем внутри домена плохих состояний уже нет
Для твоих примеров: Да, оба отлично подходят:
PKCS1 v1.5 вместо RSA-PSS
AES-CBC вместо AEAD
Если они приходят снаружи, их не надо тащить дальше как строки/enum “всех возможных алгоритмов”. Надо сразу парсить в AllowedSigAlg / AllowedAead, где плохих вариантов просто нет.