Назад к блогу

Cloudflare запускает OHTTP Gateway: приватность на уровне протокола

Cloudflare запускает OHTTP Gateway: приватность на уровне протокола

Cloudflare представил OHTTP Gateway — компонент, который позволяет разделить роли релея и сервера приложений между разными сторонами и тем самым закрыть давнюю проблему протокола Oblivious HTTP, где метаданные клиента и содержимое его запросов неизбежно сходились в одних руках. В статье разбирается механика OHTTP по RFC 9458, топология участников и то, какие данные видит каждая сторона в разных конфигурациях. Это полезно всем, кто внедряет приватность на уровне протокола и хочет понять, почему одной лишь релейной схемы недостаточно.

OHTTP (Oblivious HTTP) решает конкретную проблему: при обычном взаимодействии с сервером IP-адрес клиента раскрывает идентифицирующую информацию, которой сам клиент слабо управляет. Протокол вводит посредника — релей, который отвязывает запрос от отправителя, но у этой схемы есть жёсткое условие — релей и сервер приложения должны принадлежать разным, не сговаривающимся сторонам. Именно это условие и породило необходимость отдельного шлюза. Разберём, как устроена механика протокола и что именно запустил Cloudflare.

Зачем понадобился отдельный Gateway

OHTTP вводит релей. Релей видит идентификаторы клиента — IP-адрес и TLS-отпечаток — но удаляет их перед пересылкой запроса дальше.

Модель приватности OHTTP требует, чтобы релей и сервер приложения управлялись разными, не сговаривающимися сторонами. Из этого следует нетривиальный вывод: разработчики, чьи серверы уже были размещены за Cloudflare, не могли пользоваться Cloudflare OHTTP Relay. Cloudflare в такой конфигурации видел бы и метаданные клиента, и расшифрованное содержимое запросов одновременно — а это ломает модель приватности. Дополнительно самостоятельное создание и эксплуатация шлюза оказались сложными для клиентов, а задержка самодельной OHTTP-конфигурации может быть значительной.

Поэтому Cloudflare запустил собственный OHTTP Gateway, давая клиентам выбор: использовать Cloudflare Relay со своим шлюзом либо новый Cloudflare Gateway со сторонним релеем.

Что видит каждая сторона

Разница между режимами «только релей» и «релей + шлюз» проявляется в том, какие метаданные доходят до сервера приложения.

При использовании только релея сервер приложения видит данные самого релея, а не клиента. В примере это IP-адрес 128.62.37.13, ASN 18, шифр AEAD-AES-128-GCM-SHA256, версия TLSv1.3, а также страна US, регион Texas и город Austin. Значит, сервер приложения не узнаёт местоположение и TLS-отпечаток конечного пользователя. А если через один релей идёт множество пользователей, сервер не может различить, от кого именно пришёл запрос, — это ограничивает его способность связать активность в приложении с конкретным пользователем.

При использовании релея и шлюза между ними появляется шлюз. Возникает модель double-blind. При этом со шлюзом можно выбирать, размещать ли свои серверы за Cloudflare.

Для сравнения: без релея запрос раскрывал бы данные клиента — IP-адрес 192.0.2.33, ASN 7922, шифр AEAD-CHACHA20-POLY1305-SHA256, версия TLSv1.3, страна US, регион California, город Campbell.

Роли и топология: Client, Relay, Gateway, Target

RFC 9458 описывает цепочку из четырёх участников: клиент, Oblivious Relay Resource, Oblivious Gateway Resource и Target Resource. Взаимодействие идёт по HTTP.

Клиент отправляет запрос Oblivious Relay Resource методом POST. Это может выглядеть как HTTP/1.1-запрос с Content-Type: message/ohttp-req и телом, содержащим Encapsulated Request. Путь на этом шаге — /request.example.net/proxy, а Host — proxy.example.org.

Релей принимает запрос и пересылает его Oblivious Gateway Resource. Запрос на этом шаге тоже имеет метод POST, Content-Type: message/ohttp-req и то же тело Encapsulated Request, но путь уже /oblivious/request, а Host — example.com. Ответ возвращается с Content-Type: message/ohttp-res и телом Encapsulated Response.

Фиксированное соответствие релея и шлюза

RFC 9458 предполагает фиксированное взаимно-однозначное соответствие между Oblivious Relay Resource и Oblivious Gateway Resource. Это ограничение введено, чтобы упростить настройку релея и не дать использовать его как универсальный релей для неизвестных Oblivious Gateway Resource. Релей пересылает только тем шлюзам, которые он явно настроил и разрешил.

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

Формат ключевой конфигурации Gateway

Key Config состоит из полей: Key Identifier (8 бит), HPKE KEM ID (16 бит), HPKE Public Key (Npk*8 бит), HPKE Symmetric Algorithms Length (16 бит, значение 4..65532) и HPKE Symmetric Algorithms (32 бита и более). HPKE KEM ID — идентификатор схемы инкапсуляции ключа, HPKE KDF ID — идентификатор функции выработки ключа, HPKE AEAD ID — идентификатор алгоритма аутентифицированного шифрования. Каждое поле кодируется в сетевом порядке байт. Длина HPKE Symmetric Algorithms — это 16-битное целое в network byte order, задающее длину в байтах следующего поля. Само поле содержит одну или более пар идентификаторов: HPKE KDF ID (16 бит) и HPKE AEAD ID (16 бит).

В Appendix A приведён пример байтовой раскладки:

01002031e1f05a740102115220e9af918f738674aec95f54db6e04eb705aae8e79815500080001000100010003

Как это реализовано в коде

Приватный ключ создаётся из seed: его длина сверяется с ожидаемой для выбранной KEM-схемы, после чего из seed выводится пара ключей и создаётся набор фиксированных KEM, KDF и AEAD; приватному ключу присваивается идентификатор 0x01.

При сериализации поля записываются последовательно: сначала однобайтовый идентификатор ключа, затем двухбайтовый идентификатор KEM, затем байты открытого ключа, затем идентификаторы KDF и AEAD. При разборе те же поля читаются в обратном порядке: идентификатор ключа, идентификатор KEM с проверкой валидности, ровно PublicKeySize() байт открытого ключа, затем идентификаторы KDF и AEAD с проверками. После этого строится suite и вызывается UnmarshalBinaryPublicKey.

Формат соответствует структуре из RFC 9458: идентификатор ключа, идентификатор KEM, открытый ключ, идентификатор KDF, идентификатор AEAD. Поле HPKE Public Key в RFC описано как Npk * 8 бит, то есть Npk байт, — именно поэтому при разборе читается ровно PublicKeySize() байт.

Инкапсуляция запроса: пошагово

Клиент формирует Encapsulated Request, сначала собирая заголовок как конкатенацию четырёх закодированных полей: key_id (1 байт), kem_id (2 байта), kdf_id (2 байта), aead_id (2 байта). Затем вычисляет info:

info = concat(encode_str("message/bhttp request"), encode(1, 0), hdr)

Здесь encode_str — это кодирование строки: к её байтам добавляется префикс длины, так что строка передаётся вместе со своим размером. encode — это кодирование целого числа фиксированной ширины в сетевом порядке байт: первый аргумент задаёт ширину в байтах, второй — само значение. Поэтому encode_str("message/bhttp request") записывает строку с префиксом длины, а encode(1, 0) — один нулевой байт. К строке "message/bhttp request" в виде encode_str добавляется один нулевой байт и полученный заголовок. Далее вызывается SetupBaseS(pkR, info), что даёт инкапсулированный общий секрет KEM и контекст шифрования. Через этот контекст шифруется сам запрос с пустым aad. Итоговый Encapsulated Request — это конкатенация заголовка, инкапсулированного секрета и зашифрованного запроса, именно в таком порядке.

Декапсуляция на шлюзе

Шлюз разбирает Encapsulated Request на key_id, kem_id, kdf_id, aead_id, enc и ct. Затем находит приватный ключ по key_id; если key_id не соответствует типу kem_id, возвращается ошибка. Если kdf_id и aead_id задают комбинацию KDF и AEAD, которую шлюз не готов использовать с этим ключом, — тоже ошибка.

Далее строится info конкатенацией ASCII-строки "message/bhttp request", нулевого байта, key_id как 8-битного целого и kem_id, kdf_id, aead_id как трёх 16-битных целых. Создаётся принимающий контекст через SetupBaseR() с приватным ключом, enc и info, после чего ct расшифровывается вызовом Open() с пустым aad. Шлюз сохраняет контекст для последующей инкапсуляции ответа. Полученный запрос представляет собой Binary HTTP Message, которое отправляется в Target.

Инкапсуляция ответа: экспорт секрета и AEAD

Шлюз формирует Encapsulated Response, экспортируя секрет из контекста с меткой "message/bhttp response" и длиной max(Nn, Nk). Затем выбирается случайный response_nonce той же длины, и соль строится как конкатенация enc из запроса и этого nonce.

Соль и секрет передаются в Extract выбранного KDF (HKDF-SHA256), давая псевдослучайный ключ. Из него через Expand с info-полем "key" получается 16-байтовый ключ AEAD (AES-128-GCM), а через Expand с info-полем "nonce" — nonce для AEAD. После этого ответ шифруется, и Encapsulated Response формируется как конкатенация response_nonce и шифротекста.

Ключевая деталь: вывод ключа для ответа использует и инкапсулированный KEM-ключ из запроса, и случайно выбранный nonce — поэтому даже при повторном запросе ответы получают уникальные ключи и nonce.

Декапсуляция ответа на клиенте

Клиент обращает процесс: разбирает Encapsulated Response на response_nonce и ct, затем тем же способом выводит ключ и nonce AEAD, используя свой отправляющий контекст. Для расшифровки применяется AEAD-функция Open с пустой строкой в качестве AAD — потому что при инкапсуляции ответ шифровался с пустым aad, и при декапсуляции нужно передать тот же пустой aad, чтобы проверка целостности совпала.

Обязанности сторон и ограничения

Что обязан делать клиент

Клиент обязан для каждого запроса создавать новый HPKE-контекст и брать ключи из хорошего источника энтропии. Повторное использование ключей не только рискует связать запросы между собой, но и может раскрыть содержимое запросов и ответов релею.

Клиент или релей не должны автоматически повторять неудавшийся запрос, пока не получат положительный сигнал, что запрос не был обработан или переслан. Такими сигналами служат ошибка HTTP/2 REFUSED_STREAM, ошибка HTTP/3 H3_REQUEST_REJECTED или кадр GOAWAY с достаточно низким идентификатором. Сбои и прерывания соединения таковыми не являются.

Клиенту следует включать поле заголовка Date в инкапсулированные запросы, если он заранее не знает, что шлюз не использует Date для защиты от повторов.

Что обязан делать шлюз

Шлюзу нужен план замены ключей, например регулярная замена с назначением новых идентификаторов. При запросе с неизвестным идентификатором или идентификатором заменённого ключа шлюз может ответить кодом HTTP 422 (Unprocessable Content). Тот же код применяется, если ключ есть, но инкапсулированный запрос не удаётся расшифровать.

При ошибках инкапсуляции, включая неверную или устаревшую конфигурацию ключей, шлюз может сформировать ответ с кодом 4xx. Для указания на устаревшую или неверную конфигурацию он может использовать тип проблемы https://iana.org/assignments/http-problem-types#ohttp-key.

Обязанности релея

Релей выполняет простую функцию: на каждый полученный запрос делает запрос к шлюзу с тем же содержимым, а получив ответ, отправляет клиенту ответ с содержимым ответа от шлюза. При пересылке релей обязан следовать правилам пересылки HTTP-сообщений, которые определяют, как обращаться с полями заголовков при проксировании, — но не должен пересылать неизвестные поля, чтобы устранить риск приватности, и не должен добавлять информацию, которая может идентифицировать клиентов, кроме той, о которой клиент осведомлён.

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

Traffic analysis

Traffic analysis: HTTPS не предотвращает traffic analysis сетевыми наблюдателями. Время отправки сообщений Encapsulated Request или Response может раскрывать информацию сетевому наблюдателю. Релей мог бы задерживать запросы перед пересылкой, что может увеличить множество анонимности, но задержки следует добавлять только с согласия или осведомлённости клиентов. Вредоносный релей, терминируя TLS-соединения от клиентов, видит границы сообщений — это привилегированное положение позволяет извлекать больше признаков из зашифрованных данных и улучшать traffic analysis.

Защита от повторов и работа со временем

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

Размер окна должен допускать разницу часов и задержки сети. При недостаточной терпимости валидные запросы будут напрасно отклоняться, а конкретный размер окна может определяться экспериментально. Шлюз не должен считать временное окно секретом, так как атакующий может зондировать разные значения Date.

При расхождении часов шлюз может отклонить запрос с Date вне активного окна кодом 400-й серии с типом проблемы https://iana.org/assignments/http-problem-types#date. Пример ответа:

HTTP/1.1 400 Bad Request
Date: Mon, 07 Feb 2022 00:28:05 GMT
Content-Type: application/problem+json
Content-Length: 128
{"type":"https://iana.org/assignments/http-problem-types#date",
 "title": "date field in request outside of acceptable range"}

В ответе шлюз указывает свой Date, чтобы клиент исправил часы и повторил тот же запрос с этим значением. Но клиент не должен повторять запрос с исправленной датой более одного раза и обязан создавать свежее шифрование изменённого запроса с новым HPKE-контекстом.

Почему повтор без сигнала опасен

Запрет на автоматический повтор связан с защитой от replay. Повтор допустим только при явном сигнале, что запрос не был обработан. Защита от replay основана на том, что ключевой материал (enc) генерируется заново для каждого запроса и потому уникален: два валидных запроса с подавляющей вероятностью имеют разные значения. Сервер обязан либо отклонять повторные запросы, либо гарантировать, что последствия повторного воспроизведения не навредят клиентам или ресурсам.

Forward secrecy в течение срока действия конфигурации ключей не обеспечивается. Некоторая мера достигается сменой конфигурации ключей и удалением старых ключей через подходящий период. Даже без предотвращения replay поле response_nonce, выбираемое сервером, обеспечивает уникальные ключи и nonce AEAD для ответов при повторных запросах.

Обработка ошибок и сигнализация проблем с ключами

Когда шлюз обнаруживает, что Encapsulated Request использовал устаревшую или неверную конфигурацию ключей (например, неизвестный идентификатор), он может ответить с типом проблемы https://iana.org/assignments/http-problem-types#ohttp-key. Пример такого ответа — HTTP/1.1 400 Bad Request с телом, где указан этот тип и заголовок "key identifier unknown".

Такой ответ не может быть зашифрован, потому что ошибки в инкапсуляции запроса означают, что ответы не могут быть инкапсулированы. Из-за этого ответ может не дойти до клиента, и клиент не может полагаться на то, что шлюз использует этот тип проблемы. Клиент может быть настроен игнорировать неинкапсулированные ответы, поскольку они могут быть объектом наблюдения или модификации со стороны релея. Управлять риском устаревшей конфигурации клиент может эвристически — периодически обновляя её при получении ответа с кодом ошибки, который не был инкапсулирован.

Информационные ответы (1xx)

Инкапсуляция не допускает постепенной обработки ответов: хотя двоичный формат HTTP-ответа поддерживает включение информационных статус-кодов, снять AEAD-защиту можно только после получения всего сообщения целиком. Поэтому заголовок Expect со значением 100-continue использовать нельзя: клиенты не должны формировать запрос с таким ожиданием, а шлюз должен сгенерировать ошибку, если оно получено.

Если шлюз получает от Target Resource какой-либо ответ, он отправляет единственный ответ 200, содержащий Encapsulated Response. Этот ответ должен содержать только поля, необходимые для переноса Encapsulated Response: статус 200, поле заголовка с типом содержимого и сам Encapsulated Response как содержимое. Если шлюз не получает ответа, он может сам сгенерировать ответ с подходящим кодом ошибки (например, 504 Gateway Timeout), который затем инкапсулируется так же, как успешный ответ.

Что видит клиент в ответе

Клиент получает от релея или шлюза ответ, содержащий только те поля, что нужны для переноса Encapsulated Response: статус 200, заголовок с типом содержимого и сам Encapsulated Response как тело. Пример такого ответа включает Cache-Control: private, no-store и Content-Type: message/ohttp-res.

Ответ Target Resource напрямую клиенту не виден: шлюз, получив любой ответ от Target, отправляет один ответ 200 с Encapsulated Response, а ошибки Target или шлюза после снятия инкапсуляции также отправляются внутри Encapsulated Response. Клиент использует созданный им HPKE-контекст и nonce из Encapsulated Response, чтобы построить ключ и nonce AEAD и расшифровать ответ.

Ошибки, обнаруженные релеем или шлюзом до снятия защиты, отправляются без защиты в ответ на POST-запрос к этому ресурсу.

Почему сделано именно так: компромиссы и границы применимости

Почему Cloudflare абстрагировал сложность

Cloudflare выбрал модель, где шлюз абстрагирует сложность OHTTP от сервера приложения, потому что создание и эксплуатация шлюза могут быть трудной задачей для клиентов. Любой проксирующий контур добавляет задержку из-за лишних сетевых переходов, а расшифровка запросов и шифрование ответов ещё больше увеличивают latency. Цель — абстрагировать как можно больше сложности OHTTP, чтобы разработчики могли принимать OHTTP, продолжая обслуживать обычный HTTP.

Компромисс в том, что при использовании Cloudflare Gateway клиент полагается на управление ключами и защиту от злоупотреблений со стороны Cloudflare. Шлюз полностью управляет всеми ключами и отдаёт публичные ключи в ответ на GET-запросы к /.well-known/ohttp-gateway. Cloudflare Access запускается до расшифровки запросов, позволяя использовать стандартные политики Access для аутентификации входящего трафика и защиты шлюза от злоупотреблений. При этом шлюз отказывается расшифровывать запросы, отправленные из Cloudflare Workers или с проксируемых хостов Cloudflare, чтобы сохранить разделение доверия и не видеть одновременно идентификаторы клиентов и расшифрованные внутренние запросы.

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

Когда шлюз лучше релея

Шлюз подходит лучше, если вы хотите разместить серверы приложения на Cloudflare — за CDN или на Workers: тогда он обеспечивает соблюдение модели приватности. Также шлюз лучше, если нужно принимать OHTTP-запросы от стороннего клиента и релея, например для использования Apple LiveCallerID SDK.

Риск нарушения модели приватности — случайный запуск и релея, и шлюза на Cloudflare, из-за чего Cloudflare может увидеть одновременно идентификаторы клиентов и расшифрованные внутренние запросы. Чтобы этого не допустить, шлюз отказывается расшифровывать запросы из Cloudflare Workers или от проксируемых хостов на Cloudflare. Ещё один риск: OHTTP даёт приватность на сетевом уровне и не затрагивает тело внутреннего запроса, поэтому не следует отправлять в теле идентифицирующую информацию, например email или имя пользователя.

Разделение Gateway и Target в RFC

Разделение шлюза и Target обосновано тем, что шлюз выполняет обобщённую обработку HTTP, поэтому полностью исключить пересылку нельзя. Схема, указанная в Encapsulated Request, определяет требования безопасности для протокола между шлюзом и Target, при этом рекомендуется HTTPS.

Target, работающий на другом сервере, чем шлюз, — обычный HTTP-ресурс. Он может давать привилегии запросам, пересланным данным шлюзом, только если доверяет оператору шлюза в том, что тот пересылает лишь запросы, соответствующие ожиданиям Target; иначе Target обращается с такими запросами как с любыми другими от HTTP-клиента. Например, шлюз может считаться доверенным в том, что не пересылает чрезмерный объём запросов, что позволяет Target принимать от него больший объём, чем от прочих клиентов. Шлюз также может реализовывать политики, помогающие Target применять исключения, — например, пересылать запросы только к определённым Target по allowlist.

Что касается корреляции: независимые запросы клиента должны быть связуемы только по их содержимому, однако выбор конфигурации клиента может использоваться для корреляции запросов.

Что из этого следует на практике

  • Разделение доверия — не бюрократия, а техническое требование. Если один оператор видит и идентификаторы клиента, и расшифрованное содержимое, модель приватности OHTTP не работает. Именно поэтому Cloudflare Gateway отказывается расшифровывать запросы из Workers и от проксируемых хостов — это защита от случайного нарушения модели.
  • Множество анонимности — расходуемый ресурс. Каждая активная конфигурация клиента разделяет множество анонимности. Маленькие группы клиентов ослабляют защиту, а дифференцированная обработка абьюзивных клиентов дополнительно уменьшает размер множества анонимности.
  • Ключи нужно ротировать. Forward secrecy в течение срока действия конфигурации не обеспечивается — некоторая мера достигается сменой конфигурации и удалением старых ключей. Шлюзу нужен план замены, а клиенту — эвристика обновления конфигурации при неинкапсулированных ошибках.
  • Повтор запроса — только по явному сигналу. Автоматический retry без положительного сигнала (REFUSED_STREAM, H3_REQUEST_REJECTED, GOAWAY с низким идентификатором) рискует повторной обработкой запроса. Сбои и разрывы соединения такими сигналами не являются.
  • Ошибки до снятия защиты идут открытым текстом. Ответ с проблемой конфигурации ключей не может быть зашифрован, поэтому может не дойти до клиента и может быть изменён релеем — на него нельзя полагаться.
  • OHTTP защищает сетевой уровень, а не тело запроса. Идентифицирующую информацию в теле (email, имя пользователя) протокол не скрывает — это ответственность разработчика клиента.
  • Traffic analysis остаётся. HTTPS не мешает сетевым наблюдателям анализировать время и границы сообщений, а вредоносный релей в привилегированном положении видит границы сообщений и может извлекать больше признаков.

Где смотреть в коде

Источники

Похожее