nos_
ENRU

HTTPS прямо из ядра

Что понадобилось, чтобы wget https:// заработал в nos: rustls без std, трейт для транспорта, корневые сертификаты и RTC, пул энтропии и криптография без SSE.

10 мин чтения
СетьRustБезопасностьx86-64Развёртывание
Read in English

9 сентября 2026 года в шелле nos впервые сработала команда wget https://example.com. Ядро разрешило имя, открыло TCP-соединение, провело рукопожатие TLS 1.3 и проверило цепочку сертификатов сервера. Сегодня именно так работающий nos забирает собственные релизы с GitHub. Сервер, у которого нет другой консоли, кроме сети, сам скачивает следующее ядро, проверяет его и один раз загружается в него на пробу.

Сам протокол мы не писали, и это была самая простая часть. Больше всего работы ушло на то, чего rustls и его криптография ждут от операционной системы: стандартной библиотеки, часов, случайных байтов и регистров SSE. Разберём по очереди, чего не хватало и как это проявилось, а в конце посчитаем, во что HTTPS обходится ядру.

Почему не обычный HTTP

HTTP-клиент и команда wget в nos к тому времени уже были. Но, как сказано в коммите, который добавил TLS, половина ссылок http:// в интернете перенаправляет на https://, а клиент отказывался идти по любому такому редиректу и писал «redirect to non-HTTP» (ca8ff9c).

Писать TLS самим мы не собирались. TLS — это разбор сертификатов, сборка цепочки, проверка подписей, обмен ключами, аутентифицированное шифрование и конечный автомат. На такое уходят недели, и ошибка в этом коде оборачивается не падением, а тихой дырой. Библиотека rustls, написанная на Rust, сама не трогает сокеты: она принимает байты и отдаёт байты. Поэтому её можно было поставить поверх собственного TCP-стека ядра.

rustls без std

Rust в ядре собирается как no_std: он линкует core и alloc, но не стандартную библиотеку, поэтому здесь нет ни файлов, ни сокетов, ни часов, ни системного источника случайности. rustls умеет так работать, но его привычные read_tls и write_tls принимают трейты из std::io. Поэтому крейт tls работает с небуферизованным API rustls. process_tls_records каждый раз возвращает одно состояние, например «есть байты на отправку» или «рукопожатие ждёт собеседника». А читать, писать и буферизовать вместо std::io приходится нашему циклу.

Одно правило важнее остальных: вызывать process_tls_records один раз за оборот цикла. Функция потребляет записи по ходу работы. Если вызвать её повторно, только чтобы спросить «мы заблокированы?», она обработает следующую запись второй раз, и соединение умрёт на следующем обмене с сервером с ошибкой InappropriateMessage { got_type: ChangeCipherSpec } (HTTPS). Сами примитивы даёт rustls-rustcrypto, провайдер на основе крейтов RustCrypto, которые реализуют их на чистом Rust.

Шов между TLS и TCP

С первого дня HTTP-клиент не знает, зашифрованы ли его байты. Он собирает запрос, разбирает заголовки, декодирует chunked, ходит по редиректам и отдаёт тело в приёмник поверх транспорта, которым служит обычный TCP или TLS. Когда появился TLS, ни одну из этих частей менять не пришлось.

Сам шов пришёл к нынешнему виду в два шага. Сначала HTTP-клиент был на C++, и TLS-сессия на Rust добиралась до его TCP-соединения через два вызова C, kernel_tcp_send и kernel_tcp_recv. 18 сентября HTTP-клиент вслед за TCP переехал на Rust (30e70e7), но TLS по-прежнему ходил в TCP так же, как загружаемый модуль, через C ABI. Между двумя частями Rust в одном образе это был C ABI там, где напрашивался трейт.

Сетевой слой зависит от tls, поэтому tls не может сослаться на соединения этого слоя. Вместо этого он принимает трейт (d6dac83):

pub trait Transport {
    /// Sends the whole buffer; false if the connection failed part way.
    fn send_all(&mut self, buf: &[u8]) -> bool;

    /// Bytes read, 0 at the end of the stream, negative on an error or when
    /// `timeout_ms` went by with nothing received.
    fn recv(&mut self, buf: &mut [u8], timeout_ms: u64) -> isize;
}

http.rs передаёт TlsStream обёртку TcpPipe, которая держит соединение и вызывает TCP напрямую. Когда TlsStream уничтожается, он только отправляет close_notify, то есть вежливо закрывает TLS-сессию, а само соединение закрывает HTTP-клиент.

Сертификаты и часы

Корневые сертификаты берутся из webpki-roots, вкомпилированной копии хранилища Mozilla. Хранилище корневых сертификатов — это набор удостоверяющих центров, на одном из которых должна заканчиваться цепочка, чтобы ей можно было доверять. В нашей копии их 121. Чтобы проверить срок действия, rustls нужно текущее время, а без std ему его не узнать. Поэтому ядро отдаёт ему своё: показания часов реального времени (RTC), прочитанные при загрузке, плюс время, прошедшее с тех пор.

impl TimeProvider for KernelTime {
    fn current_time(&self) -> Option<UnixTime> {
        let secs = kcore::time::wall_clock_secs();
        if secs == 0 {
            return None;
        }
        Some(UnixTime::since_unix_epoch(Duration::from_secs(secs)))
    }
}

Машина с неправильными часами отвергает любой сертификат, и это безопасный вариант отказа. Ключа --insecure нет. Хосты badssl.com нарочно отдают сломанные сертификаты, и ядро отвергло все, что мы проверили: просроченный, самоподписанный, с недоверенным корнем и выписанный на чужое имя. Причину каждого отказа оно записало в лог (HTTPS):

tls: InvalidCertificate(ExpiredContext { time: UnixTime(1788984857),
                                         not_after: UnixTime(1428883199) })

Первое число — 9 сентября 2026 года, тот самый вечер, когда всё впервые заработало. Второе — 12 апреля 2015 года.

Тест с чужим именем к тому же уронил машину, хотя виноват был размер стека, а не TLS. Пока ядро проверяло этот сертификат, оно переполнило 32-килобайтный стек задачи шелла и остановилось с BUG в GetCurrentTask: эта функция находит текущую задачу по указателю стека и заодно проверяет сам стек. Проверка цепочки рекурсивно перебирает возможных издателей. Раньше задача шелла занимала на пике около 4 КБ стека, а во время рукопожатия доходит примерно до 29 КБ, поэтому стеки задач выросли до 64 КБ.

Случайные байты

Крейты RustCrypto получают случайные байты через крейт getrandom, который умеет спрашивать их у операционной системы. Бэкенда для голого железа у него нет, зато можно подключить свой, и крейт tls регистрирует ядерный:

fn kernel_getrandom(buf: &mut [u8]) -> Result<(), getrandom::Error> {
    if kcore::random::fill_random(buf) {
        Ok(())
    } else {
        Err(getrandom::Error::UNSUPPORTED)
    }
}

getrandom::register_custom_getrandom!(kernel_getrandom);

Под QEMU это работало: там у ядра есть virtio-rng, виртуальное устройство, через которое хост передаёт гостю энтропию. А на сервере Hetzner первый же wget https:// упал на самом первом шаге рукопожатия:

tls: connection: FailedToGetRandomBytes
TlsConn: handshake with github.com failed

Генератора случайных чисел в ядре не было, была только таблица источников энтропии. Единственным источником, который успели написать, был virtio-rng, а его даёт гипервизор. На голом железе таблица оказалась пустой.

В тот же вечер в ядре появился пул на ChaCha20, устроенный на тех же двух идеях, что и генератор Linux (72432cf). Материал для посева смешивается с ключом через XOR, поэтому бесполезный источник не может сделать пул хуже. Каждый ответ заменяет ключ, из которого получился, так что ни по одному ответу нельзя восстановить предыдущие. Пул засевают RDSEED и RDRAND на x86-64, RNDR на arm64, virtio-rng и, на крайний случай, разброс времени выполнения кода. Незасеянный пул отказывается отвечать: рукопожатие на ключах из нулей хуже, чем никакого.

С пулом wget https://github.com/robots.txt вернул 200. С hwrng=off и без virtio-rng пул засеялся одним только разбросом времени, и HTTPS-запрос всё равно прошёл. В октябре пул переехал на Rust (cf87887), а устроен он так, как описано на странице «Случайные числа».

Без SSE и x87

Ядро держит SSE и x87 выключенными: при переключении контекста оно не сохраняет ни те, ни другие регистры. Две задачи, которые ими пользуются, делили бы один набор и молча портили бы друг другу значения. Поэтому Rust в ядре собирается под цели с программной плавающей точкой (soft-float), x86_64-unknown-none и aarch64-unknown-none-softfloat. На такой цели компилятор превращает арифметику с плавающей точкой в вызовы вспомогательных функций вроде __muldf3, которые считают на целочисленных регистрах. Эти функции берутся из compiler_builtins, библиотеки встроенных функций компилятора Rust, а с 22 сентября их вызывает и C++, который теперь собирается не только с -mno-sse, но и с -mno-80387 (bec20c2).

Крейты RustCrypto выбирают SSE2 или AVX2 во время выполнения, а компилируют эти ветки безусловно. LLVM не может собрать их для цели без SSE, и сборка остановилась с ошибкой Do not know how to split the result of this operator. У каждого крейта есть документированный переключатель на переносимый программный бэкенд, который работает за постоянное время, и .cargo/config.toml включает эти переключатели для обеих целей:

[target.x86_64-unknown-none]
rustflags = ["-Ccode-model=large", "-Cforce-frame-pointers=yes", "--cfg", "aes_force_soft", "--cfg", "polyval_force_soft", "--cfg", "poly1305_force_soft", "--cfg", "chacha20_force_soft", "--cfg", "curve25519_dalek_backend=\"serial\""]

Ошибка сборки — это дружелюбный вариант такой ловушки. На следующий день, пока мы добавляли в шелл команду sha256, нашёлся и тихий вариант (6d3ffd5). У sha2 такого cfg нет. Он выбирает SHA-NI, SHA-инструкции процессора, по CPUID во время выполнения и при этом собирается без единого предупреждения. Но SHA-NI относятся к инструкциям SSE, а процессор разрешает их, только если в CR4 выставлен бит OSFXSR.

Вторичный процессор (AP) — это любой процессор, кроме того, на котором загрузилась машина. Он стартует по сигналу INIT, который обнуляет CR4, а загрузочный код выставлял ему только PAE и MCE: AX41 писал в лог cr4=0x60 для каждого AP. Значит, первый же блок, захэшированный на AP, обернулся бы исключением invalid opcode, причём и в TLS-клиенте. Теперь kernel/Cargo.toml включает у sha2 фичу force-soft, и TLS это тоже касается, потому что крейт собирается один раз на весь граф зависимостей.

Три детали HTTP-клиента

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

Теперь тело уходит в приёмник по мере поступления. Клиент держит один буфер на 16 КиБ, где сначала лежат заголовки, а потом кусок за куском тело, и wget пишет файл блоками по 64 КиБ. Скачать 20 МБ, а это предел, стоит столько же памяти, сколько скачать 200 байт.

Клиент следует редиректам и на http, и на https, не больше пяти раз подряд, поэтому ссылка http://, которая отвечает 301 на https://, скачивается целиком. Тело редиректа клиент не читает вовсе.

URL никогда не обрезаются. На следующий день после того, как заработал HTTPS, команда wget для файла из релиза на GitHub вернула вместо него страницу ошибки CDN (5de70df):

$ wget https://github.com/irqlevel/nos/releases/download/v0.0.18/nos.iso
HTTP 618, 457 bytes
... <title>618 jwt:jwt-not-provided</title> ... Varnish cache server

github.com отвечает на адрес релиза редиректом 302 на подписанную ссылку с хоста release-assets.githubusercontent.com. В ссылке 891 символ, а в её параметрах лежат подпись и JWT (подписанный токен). Клиент обрезал её в трёх местах: до 255, 383 и 255 символов. Если файл уже лежал в кэше CDN, обрезанную ссылку всё равно обслуживали, поэтому одна и та же команда то работала, то нет, в зависимости от того, кто скачивал файл последним. Теперь URL может быть длиной до 2047 символов, а с более длинным запрос завершается ошибкой: обрезанный URL — это уже другой URL.

Когда всё это заработало, ядро научилось обновлять само себя. На серверах Hetzner, которые настраивали вручную, новое ядро ставится из UDP-шелла («Реальное железо»):

wget https://github.com/irqlevel/nos/releases/latest/download/kernel-x86_64.elf /nos-kernel64.elf.next
sha256 /nos-kernel64.elf.next        # against the release's SHA256SUMS
grubenv /grubenv nos_next=nos-next   # one boot of the candidate
reboot

GRUB загружает кандидата один раз, а основным новое ядро делает себя само, уже после того как поднялось. Поэтому ядро, которое не дошло до шелла, не может заменить то, которое дошло.

Во что это обходится

  • Около 4 МБ образа ядра, в основном корневые сертификаты и криптография.
  • Стеки задач по 64 КБ вместо 32 КБ. После того как URL стали длиннее, задача команд шелла на пике занимала 31 640 байт из своих 65 536.
  • Два буфера записей TLS по 18 КиБ на каждую сессию, по одному на направление.
  • Программный ChaCha20-Poly1305 даёт под QEMU несколько МБ/с. Окно приёма TCP в 8 КБ ограничивает скачивание задолго до шифра.
  • Сборка потяжелела. Это были первые внешние зависимости в дереве, поэтому их исходники лежат в репозитории, а cargo запускается с --offline (4927a77). scripts/vendor.sh опустошает пакеты, которые не компилирует ни одна цель, и вместо 143 МБ остаётся 31.
  • Nightly-тулчейн закреплён на конкретной дате. Более свежий сломал офлайн-сборку из-за hermit-abi, крейта, который nos вообще не компилирует (6a05f66).

Три недели спустя

Сетевой фаззер проверил клиент в паре с сервером на rustls, у которого свой удостоверяющий центр. Он поймал TLS-клиент на том, что тот принимал поток, оборванный посреди записи, или приём, который кончился таймаутом, за нормальный конец потока. Из-за этого зависшее тело без указанной длины выглядело целым. Теперь и то и другое считается ошибкой (c68452d), а проверку сертификатов по-прежнему нельзя отключить.