🔬 AmneziaWG 3.0: внутреннее устройство протокола

О чём заметка

Побайтовый разбор третьего поколения AmneziaWG по исходному коду: как собирается пакет, что именно шифрует защита заголовков, откуда берётся одноразовое число, как приёмная сторона опознаёт тип сообщения, не расшифровав его целиком, и какие следы протокол всё ещё оставляет в сети. Обзорная часть — назначение, доступность, история версий — в заметке AmneziaWG 3.0; параметры предыдущего поколения (Jc, S1S4, H1H4, язык CPS) подробно разобраны в справочнике по AmneziaWG 2.0 и здесь не повторяются.

TL;DR

  • Третье поколение добавляет к обфускации ровно три механизма: защита заголовков (ChaCha20 поверх готовых сообщений WireGuard), content padding (случайное удлинение полезной нагрузки вместо выравнивания по 16 байт) и диапазонные тайминги (новое случайное значение при каждом взводе таймера).
  • Одноразовое число (nonce) для шифра не передаётся отдельно — им служат первые 12 байт того самого случайного паддинга S1S4, который в 2.0 был просто мусором. Отсюда требование S1S4 ≥ 12 при включённой защите.
  • Приёмная сторона использует изящный приём: шифрует блок нулей, получая первые 4 байта потока ключа, и этим «хэшем типа» расшифровывает только поле типа — чтобы опознать сообщение до разбора остального пакета.
  • Тихое улучшение, не отмеченное нигде в документации: keepalive-пакеты теперь получают и паддинг S4, и content padding. В AWG 2.0 они шли без S4 и имели постоянный размер 32 байта — это была заметная сигнатура. У улучшения оказался побочный эффект: до исправления 5 августа 2026 года западдженный keepalive засчитывался как пакет с данными, и простаивающий туннель делал рукопожатие каждые ~15 секунд — примерно в десять раз чаще нормы; разбор ниже.
  • Что 3.0 не закрывает: размеры рукопожатий остаются постоянными для конкретной конфигурации (S1+148 и S2+92 байта), потому что content padding применяется только к транспортным пакетам. Пара «фиксированный запрос → фиксированный ответ → поток» по-прежнему видна наблюдателю. Именно этот признак закрывает линия 3.1 от 12 августа 2026 года параметром RandomTrailers — см. обзорную заметку.

Как проверялись факты

Всё ниже прочитано в исходниках движка amneziawg-go на теге v3.0.3 (коммит cf9d2dd, 31 июля 2026 года) — это последняя на 4 августа 2026 года версия третьего поколения. Ключевые файлы: device/noise-types.go (константы и тип диапазона), device/noise-protocol.go (выдача шифра), device/send.go (сборка исходящих пакетов), device/receive.go (разбор входящих), device/uapi.go (параметры и их проверка), device/timers.go (тайминги).

После 4 августа линия получила продолжение: 5 августа 2026 года в go и в модуле ядра вышли теги v3.0.20260805 с исправлением keepalive-дефекта (разобран ниже), а 12 августа — линия 3.1 с параметрами RandomTrailers и DisableCookies, обзор которой — в заметке про AmneziaWG 3.0. Описанную здесь механику 3.0 эти теги не меняют: формат пакетов и разбор тегов CPS в 3.1 идентичны 3.0 (сверено диффом 20 августа 2026 года). Раздел о наборах тегов сигнатур дополнительно сверен с модулем ядра (src/junk.c, теги v3.0.20260805 и v3.1.20260812).

Первоисточник здесь — код, а не документация

Официального описания механики третьего поколения не существует: к 20 августа 2026 года страница docs.amnezia.org об AmneziaWG обзавелась таблицей параметров вплоть до 3.0, но с оговоркой, что подробности добавят после выхода self-hosted-поддержки; обещанная статья в блоге так и не вышла. README репозитория покрывает список параметров, но не механику. Поэтому разбор ниже — чтение исходников, и любые расхождения с будущей официальной документацией следует трактовать в её пользу. Отдельно предупреждение о ходящем по сети «разборе под капотом» из GitHub Discussions: значительная часть его утверждений кодом не подтверждается, подробности — в обзорной заметке.

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

Третье поколение AmneziaWG (AWG 3, «амнезия 3.0», «АмнезияВГ 3») не переизобретает конфигурацию, а дописывает к ней восемь новых ключей. Полная картина того, что понимает движок v3.0.3 (имена в конфигурационном файле и соответствующие им ключи внутреннего интерфейса UAPI, через который утилиты общаются с движком):

В конфигеКлюч UAPIТипПоявилсяСторона
Jc, Jmin, Jmaxjc, jmin, jmaxint1.0клиентская
S1S4s1s4int1.0 (S3, S4 — в 2.0)серверная
H1H4h1h4диапазон uint321.0 (диапазоны — в 2.0)серверная
I1I5i1i5строка на языке CPS1.5клиентская
HeaderProtectionKeyheader_protection_key32 байта, base643.0серверная
ContentPaddingAdditioncontent_padding_additionдиапазон uint323.0клиентская
RekeyAfterTimerekey_after_timeдиапазон uint32, секунды3.0клиентская
RekeyTimeoutrekey_timeoutдиапазон uint32, секунды3.0клиентская
RejectAfterTimereject_after_timeдиапазон uint32, секунды3.0клиентская
KeepaliveTimeoutkeepalive_timeoutдиапазон uint32, секунды3.0клиентская
MaxHandshakeAttemptsmax_handshake_attemptsдиапазон uint32, попытки3.0клиентская
PersistentKeepalivepersistent_keepalive_intervalстал диапазономизменён в 3.0клиентская

Разделение на «стороны» в терминологии README означает буквально следующее: серверные параметры обязаны совпадать на обоих концах туннеля, иначе стороны не поймут друг друга; клиентские можно задавать только на одной стороне — они влияют на то, как эта сторона отправляет, и не требуют согласования.

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

Формат диапазона — a-b, либо одиночное число (тогда границы совпадают), либо (off). Разбирается он в тип UintRange, где обе границы упакованы в одно 64-битное число: младшие 32 бита — нижняя граница, старшие — верхняя. Такая упаковка позволяет менять диапазон атомарно, без блокировок, прямо во время работы туннеля.

Слои обфускации: как собирается исходящий пакет

Полезно держать в голове порядок, в котором данные обрастают слоями. Для транспортного пакета он такой:

  1. Прикладные данные приходят из виртуального сетевого интерфейса.
  2. Content padding — в хвост дописываются нулевые байты: случайное количество из диапазона ContentPaddingAddition, а если параметр не задан — ровно столько, чтобы длина стала кратна 16 (штатное поведение WireGuard).
  3. Шифрование полезной нагрузки — ChaCha20-Poly1305 сеансовым ключом, штатный механизм WireGuard. На выходе получается шифртекст плюс 16-байтовая метка подлинности.
  4. Заголовок WireGuard — 16 байт: тип сообщения (значение из диапазона H4), индекс получателя, счётчик пакетов.
  5. Криптопаддинг S4 — перед заголовком в буфере лежат S4 случайных байт.
  6. Защита заголовков — 16 байт заголовка шифруются ChaCha20 с одноразовым числом из первых 12 байт паддинга.

Ключевая перестановка относительно второго поколения — в шаге 5. Раньше паддинг S4 дописывался в самом конце, сдвигом уже готового зашифрованного пакета вправо по буферу. Теперь место под него резервируется заранее (elem.padding выставляется при создании исходящего элемента), и заполняется он до шифрования заголовка — иначе неоткуда было бы взять одноразовое число.

Для рукопожатия порядок другой и полностью сохраняет логику 2.0: сначала уходят сигнатурные пакеты I1I5 (каждый — отдельная датаграмма), затем Jc мусорных пакетов, и лишь потом само сообщение инициации. Всё это отправляется одним системным вызовом.

Header protection побайтово

Ключ

Параметр HeaderProtectionKey — 32 байта, в конфигурационном файле записывается в base64, как обычный ключ WireGuard; шестнадцатеричная форма используется только на внутреннем интерфейсе UAPI. Генерируется командой awg genkey.

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

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

Одноразовое число

Шифр — ChaCha20 в варианте IETF, без аутентификации (chacha20.NewUnauthenticatedCipher). Его одноразовое число занимает 12 байт, и берётся оно не из отдельного поля, а из начала криптопаддинга:

Отправка (сообщение инициации):
  buf = [ S1 байт ]              ← crypto/rand.Read по всей длине
        [ 148 байт сообщения ]
  nonce = buf[:12]               ← первые 12 байт паддинга
  cip = ChaCha20(key, nonce)
  cip.XORKeyStream(packet, packet)   ← шифруется всё сообщение целиком

Отсюда единственное жёсткое ограничение третьего поколения: при непустом ключе все четыре значения S1S4 должны быть не меньше 12, иначе одноразовое число просто не поместится. Проверка стоит в обработчике настроек и отвергает всю конфигурацию целиком.

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

Что именно шифруется

Тип сообщенияРазмерЧто покрывает шифр
Инициация рукопожатия148 байтвсё сообщение, включая MAC1 и MAC2
Ответ на рукопожатие92 байтавсё сообщение целиком
Ответ с cookie64 байтавсё сообщение целиком
Транспортный пакет16 байт заголовкатолько заголовок: тип, индекс получателя, счётчик

Полезная нагрузка транспортных пакетов вторым слоем не шифруется, и это правильно: она уже зашифрована ChaCha20-Poly1305 и статистически неотличима от случайных данных, так что второй проход дал бы нулевой выигрыш в маскировке при заметной трате процессора.

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

Приём: трюк с «хэшем типа»

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

Решение опирается на свойство потокового шифра — шифрование блока нулей даёт чистый поток ключа:

Приём (любой пакет):
  nonce    = packet[:12]                  ← первые 12 байт датаграммы
  cip      = ChaCha20(key, nonce)
  typeHash = cip.XORKeyStream(0x00000000) ← первые 4 байта потока ключа

  для каждого кандидата (init / response / cookie / transport):
      если размер пакета == S_i + размер_сообщения:
          тип = packet[S_i : S_i+4] XOR typeHash    ← расшифровка только поля типа
          если тип попадает в диапазон H_i → это оно

  далее поток ключа продолжается с 5-го байта:
      cip.XORKeyStream(packet[4:конец_заголовка])

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

Отдельная приятная деталь: при выключенной защите заголовков «хэш типа» состоит из нулей, а операция «исключающее ИЛИ» с нулями ничего не меняет. Один и тот же код работает в обоих режимах без ветвлений — источник целого класса ошибок здесь просто отсутствует.

Как выглядит пакет на проводе

Транспортный пакет (третье поколение, защита включена):

┌───────────┬────────────────┬──────────────────────┬───────────────────────────┬───────────┐
│ nonce     │ остаток S4     │ заголовок (16 байт)  │ шифртекст полезной        │ метка     │
│ 12 байт   │ S4-12 байт     │ ЗАШИФРОВАН ChaCha20  │ нагрузки + content padding│ Poly1305  │
│ случайных │ случайных      │ тип│получатель│счётчик│                           │ 16 байт   │
└───────────┴────────────────┴──────────────────────┴───────────────────────────┴───────────┘
 └── открытым текстом, но неотличимо от шума ──┘  └── и до, и после: сплошная псевдослучайность ──┘

Сообщение инициации рукопожатия:

┌───────────┬───────────────┬──────────────────────────────────────────────┐
│ nonce     │ остаток S1    │ 148 байт сообщения, ЗАШИФРОВАННЫХ ЦЕЛИКОМ    │
│ 12 байт   │ S1-12 байт    │ (тип, отправитель, ключи, метка времени,     │
│           │               │  MAC1, MAC2 — всё)                           │
└───────────┴───────────────┴──────────────────────────────────────────────┘
Итоговый размер: S1 + 148 байт — постоянный для данной конфигурации

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

Content padding: как считается добавка

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

  • если ContentPaddingAddition не задан, возвращается признак «нет добавки», и работает обычное выравнивание длины до кратности 16;
  • если задан, из диапазона берётся случайное число;
  • добавка ограничивается сверху свободным местом до MTU (максимального размера передаваемого блока), чтобы не спровоцировать фрагментацию;
  • полученное количество нулевых байт дописывается в конец открытого текста, после чего всё вместе шифруется.

Два следствия, важных на практике. Первое: добавка попадает внутрь зашифрованной части, наблюдатель видит только изменившуюся длину пакета — сами байты паддинга он отличить от данных не может. Второе: заданный content padding отменяет штатное выравнивание по 16 байт. Именно в этом смысл механизма — предсказуемая сетка длин, кратных шестнадцати, сама по себе является признаком, по которому поток можно отнести к WireGuard-подобным.

Keepalive тоже паддится — это скрытое улучшение

Служебные пакеты поддержания соединения проходят тот же путь, что и обычные: паддинг S4 им назначается при создании исходящего элемента, а content padding считается от нулевого размера полезной нагрузки. В версии 2.0 keepalive шёл в обход S4 и всегда весил ровно 32 байта — постоянный размер, повторяющийся строго по таймеру, то есть отличный опознавательный признак. Ни в README, ни в анонсах это изменение не упомянуто.

Побочный эффект: рукопожатие каждые 15 секунд вместо двух минут

У паддинга keepalive обнаружился дефект, живший в релизах третьего поколения с 24 июля по 5 августа 2026 года. Обе реализации опознавали keepalive по длине пакета, а паддинг эту длину изменил. В amneziawg-go тега v3.0.3 проверка «отправлены ли данные» сравнивает длину уже собранного пакета с 32 байтами (len(elem.packet) != MessageKeepaliveSize в device/send.go), но к этому моменту в пакет уже вшит префикс S4 — при любом ненулевом S4 каждый keepalive засчитывается как данные. Второй, независимый путь — content padding: расшифрованный keepalive у приёмника перестаёт быть пустым, проверка len == 0 в device/receive.go не срабатывает, и пакет уходит в ветку «получены данные».

Следствие для простаивающего туннеля: отправка «данных» взводит таймер нового рукопожатия на KeepaliveTimeout + RekeyTimeout — по умолчанию 10 + 5 = 15 секунд, — а погасить его на молчащем туннеле нечем. Получается самоподдерживающийся цикл: рукопожатие → подтверждающий keepalive → таймер → новое рукопожатие, и так каждые ~15 секунд. Замеры в issue #186 модуля ядра: медиана интервала между рукопожатиями 15,0 с при S4 = 17 против 147–148 с у контроля без S4 — примерно десятикратный рост. Перед каждым лишним рукопожатием, как обычно, уходят Jc мусорных и I1I5 сигнатурных пакетов, так что дефект не просто тратил трафик, а умножал самую заметную часть почерка протокола.

Границы дефекта по реализациям различаются. В модуле ядра он старше третьего поколения: issue #186 создано 15 июля 2026 года, за две недели до выхода 3.0 в модуле, — проверка is_keepalive = skb->len == message_data_len(0) в src/send.c выполнялась после добавления S4-мусора, то есть ошибка касалась и установок AWG 2.0 с ненулевым S4. В go-движке дефект появился именно в линии 3.0 — на v0.2.19 та же проверка шла до добавления паддинга, и keepalive распознавался корректно (полевые замеры в том же issue: медиана 15,0 с на v3.0.3 против 132,0 с на v0.2.19).

Исправление: в модуле ядра — PR #208 (смержен 5 августа 2026; keepalive теперь помечается явным флагом, а приёмник считает keepalive-ом и пакет из одних нулевых байт), вошёл в теги v3.0.20260805 и v3.1.20260812. В go исправление вышло в тот же день тегом v3.0.20260805. На более ранних сборках дефект лечился бы только нулевым S4, что при включённой защите заголовков невозможно (S4 ≥ 12), — поэтому обновление обязательно.

Тайминги: где берётся случайное значение, а где граница

Шесть таймеров стали диапазонами, но применяются они не одинаково, и разница принципиальна для устойчивости туннеля.

Случайное значение при каждом взводе (PickOne берёт новое число из диапазона всякий раз): интервал переустановки ключей, пауза перед повтором рукопожатия, таймер отправки keepalive, интервал PersistentKeepalive, максимальное число попыток рукопожатия. Именно эти вызовы и размывают ритм соединения — два подряд рукопожатия не совпадут по времени.

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

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

Что изменилось относительно AWG 2.0

АспектAWG 2.0AWG 3.0
Поле типа сообщениязначение из диапазона H1H4, открытозашифровано
Индекс получателя и счётчикоткрыты, предсказуемызашифрованы
MAC1 и MAC2 в рукопожатияхоткрытызашифрованы вместе со всем сообщением
Паддинг S1S4чистый мусормусор плюс источник одноразового числа
Минимум S1S40 (любое)12 при включённой защите заголовков
Момент добавления S4сдвиг готового пакета вправоместо резервируется заранее
Keepaliveбез S4, ровно 32 байтас S4 и content padding
Длина полезной нагрузкивыравнивание до 16 байтслучайная добавка из диапазона
Таймингиконстанты WireGuardдиапазоны, новое значение на каждый взвод
Мусорные и сигнатурные пакетыJc/Jmin/Jmax, I1I5, язык CPSбез изменений
Криптография WireGuardне меняласьне менялась

Последнюю строку стоит проверить отдельно, поскольку вокруг неё много домыслов: файл с криптографическими примитивами (device/noise-helpers.go) в теге v3.0.3 побайтно совпадает с оригиналом из wireguard-go. Рукопожатие Noise IKpsk2, эллиптическая кривая Curve25519, хэш Blake2s — всё нетронуто. Защита заголовков надстроена поверх готовых сообщений и в выработке ключей не участвует.

Анализ: что протокол закрывает, а что оставляет видимым

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

Что закрыто

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

Сигнатура keepalive. Постоянный 32-байтовый пакет, приходящий строго по таймеру, был удобной зацепкой даже при полностью зашифрованном содержимом: важен не смысл байтов, а сам факт периодического повтора одинакового размера. Теперь размер плавает за счёт S4 и content padding, а момент отправки — за счёт диапазонного таймера.

Сетка длин, кратных 16. Content padding убирает регулярность, которая выдавала внутреннее выравнивание.

Что осталось

Постоянные размеры рукопожатий. Content padding применяется только к транспортным пакетам; сообщения инициации и ответа собираются в буфер фиксированной длины — S1+148 и S2+92 байта соответственно. Для конкретной конфигурации это две константы, и характерный обмен «датаграмма размера X → в ответ датаграмма размера Y → следом поток» никуда не делся. Универсальной сигнатуры здесь нет, поскольку S1 и S2 у каждой установки свои, но структурный признак — короткий двухшаговый обмен фиксированных размеров перед началом потока — наблюдаемый. Ровно этот признак закрывает вышедшая 12 августа 2026 года линия 3.1: параметр RandomTrailers дописывает к пакетам рукопожатия хвост случайной длины, но требует включения на обеих сторонах — разбор в обзорной заметке.

Молчание в ответ на активное зондирование. Пакет, не подошедший ни под один размер и диапазон заголовков, просто отбрасывается без ответа. Для системы, которая проверяет подозрительный адрес отправкой мусора, «сервер, который не отвечает вообще ничем на порт UDP» — тоже поведенческий признак. Механизм отката на локальный веб-сервис, который решал бы эту задачу по образцу REALITY, в репозитории существует, но живёт в экспериментальной ветке и в релиз третьего поколения не вошёл.

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

Блокировка по адресу. Если адрес сервера уже в списках, смена версии протокола не поможет. Это ограничение общее для всех решений такого класса, см. обзор сетевых блокировок и прогноз по волнам блокировок.

Криптографические заметки

Не уязвимости, а свойства конструкции, о которых стоит знать.

Ключ защиты заголовков статичен на всё время жизни интерфейса, а одноразовое число имеет длину 96 бит и берётся из криптостойкого генератора случайных чисел. Повтор одноразового числа означал бы повторное использование потока ключа — для потокового шифра это ведёт к утечке: исключающее ИЛИ двух заголовков раскрывается. Оценка запаса по «парадоксу дней рождения»: пятидесятипроцентная вероятность совпадения достигается примерно после 2⁴⁸ пакетов — это порядка 10¹⁴ штук, то есть годы непрерывной работы канала на гигабитной скорости. Практического риска нет, но и бесконечным запас не является, а ротации этого ключа в протоколе не предусмотрено.

Шифр применяется без аутентификации, и это корректно: подлинность обеспечивают штатные механизмы WireGuard (MAC1 и MAC2 в рукопожатиях, метка Poly1305 в транспортных пакетах), которые остались на месте. Расшифровка заголовка сама по себе ничего не подтверждает — она лишь позволяет опознать пакет, а решение о доверии принимается ниже по конвейеру.

Накладные расходы

К каждому транспортному пакету добавляется минимум 12 байт паддинга плюс случайная добавка content padding. На типичном пакете в 1400 байт минимальный обязательный оверхед составляет около 0,9% от полосы, что для практических целей несущественно; настроенный на широкий диапазон content padding способен добавить заметно больше — это осознанный размен скорости на маскировку. Вычислительная нагрузка мала: ChaCha20 обрабатывает 16 байт заголовка на пакет, а рукопожатия происходят раз в пару минут.

Ловушки, замеченные в коде

Сообщение об ошибке нумерует параметры с нуля

Если движок отвергает конфигурацию, он пишет S0 must be more then 12 to use headerProtection. Параметра S0 не существует: проверка перебирает четыре значения в цикле и подставляет индекс массива, начинающийся с нуля, так что S0 в сообщении означает S1, S1 означает S2 и так далее. При диагностике прибавляйте единицу. В той же строке живёт опечатка «more then» вместо «more than», а формулировка «больше 12» неточна — проверка отвергает значения меньше 12, само число 12 допустимо.

Ещё три момента, на которых легко споткнуться:

Снять параметр обфускации «на живую» нельзя — только пересозданием интерфейса. Команды awg setconf и awg syncconf для AWG-параметров работают на добавление и изменение: обе реализации трогают только те ключи, которые присутствуют в переданной конфигурации, а про отсутствующие ничего не знают — удалённая из файла строка не обнуляет значение на работающем интерфейсе. Изменить значение так можно, убрать совсем — нет: интерфейс придётся пересоздать (awg-quick down/up или systemctl restart awg-quick@awg0), что на сервере оборвёт соединения всех клиентов. Наблюдение мейнтейнера стороннего установщика amneziawg-installer, согласуется с устройством обеих реализаций: и netlink-обработчик модуля ядра, и UAPI-парсер go обрабатывают только присутствующие атрибуты.

Требование к паддингу распространяется на все четыре значения сразу. Даже если вы, скажем, не собираетесь получать cookie-ответы под нагрузкой, S3 всё равно обязан быть не меньше 12 — проверка не смотрит, какие типы сообщений реально используются.

README до 31 июля 2026 года указывал порог 8. В коде порог всегда был 12; расходились именно документация и текст ошибки, исправленные коммитом ce7cf103 в составе тега v3.0.3. Конфигурации, собранные по более ранней инструкции, работать не будут.

Набор тегов сигнатур зависит от реализации. README перечисляет пять тегов языка CPS, но фактические наборы двух реализаций расходятся в обе стороны (сверено 20 августа 2026 года: device/obf.go движка go на тегах v3.0.3/v3.1.20260814, src/junk.c модуля ядра на v3.0.20260805/v3.1.20260812):

Тегamneziawg-goМодуль ядра
<b>, <r>, <rc>, <rd>, <t>естьесть
<d>, <ds>, <dz> — недокументированныеестьнет — конфиг отвергается с -EINVAL
<c> — недокументированный счётчик спецпакетов (u32 big-endian, стартует со случайного значения при инициации рукопожатия)нетесть (parse_c_tag в src/junk.c)

Практическое следствие: конфигурация с <c> работает на узле с модулем ядра, но не поднимется на userspace-движке — то есть в LXC и Docker, на роутерах, на macOS; конфигурация с <d>/<ds>/<dz> — ровно наоборот. Отказ в обоих случаях жёсткий, но заметен по-разному. Движок go пишет в журнал демона строку вида failed to parse I1: unknown tag <c> (клиенту по UAPI уходит только код ошибки). Модуль ядра возвращает голый -EINVAL, а поясняющую строку I1-packet invalid format печатает через net_dbg_ratelimited() — без включённого dynamic debug в dmesg пусто; отсюда жалобы вида «интерфейс просто не поднимается, в логе ничего». Правило для рецептов I1I5, которые предлагаются другим людям: использовать только пересечение {b, r, rc, rd, t}. Теги <d>/<ds>/<dz> в пакетах I1I5 и так почти бесполезны — они получают на вход пустые данные; полный разбор go-набора — в справочнике по AmneziaWG 2.0. В линиях 3.0 и 3.1 наборы обеих реализаций идентичны, go-набор не менялся с 2.0.

Практика: как это отлаживать

  • Сгенерировать ключ защиты заголовков: awg genkey из пакета amneziawg-tools версии v3.0.20260730 или новее — более старые утилиты не знают новых ключей конфигурации и молча их не передадут.
  • Проверить, что все четыре значения S1S4 не меньше 12; при ошибке про S0 смотреть на S1.
  • Убедиться, что HeaderProtectionKey побайтно одинаков на сервере и клиенте — при расхождении соединение не установится, а в журнале не будет ничего осмысленного: пакеты просто отбрасываются как неопознанные.
  • Поднять уровень журналирования переменной окружения LOG_LEVEL=debug — сообщения о неопознанных пакетах и об инициализации шифра идут именно туда.
  • На модуле ядра пояснения к отказам (I1-packet invalid format, S0 must be more then 12…) по умолчанию не печатаются: awg показывает голое Unable to modify interface: Invalid argument. Включить отладку: echo "module amneziawg +p" > /sys/kernel/debug/dynamic_debug/control, повторить неудачную команду, посмотреть dmesg | tail, затем выключить той же командой с -p.
  • На Linux с модулем ядра брать ревизию не ниже v3.0.20260805: в ней исправлен keepalive-дефект с рукопожатиями каждые 15 секунд, а в ревизиях до v3.0.20260731-04 вдобавок падала сборка на ядрах старше 6.7 и мог молча не применяться ключ защиты заголовков.
  • Ревизию загруженного модуля смотреть командой cat /sys/module/amneziawg/version: у DKMS-сборки там строка тега (например, 3.1.20260812), у модуля, собранного вручную через make, — 1.0.0. Версия apt-пакета для проверки не годится: она всегда начинается с 1.0.0 (версия debian-упаковки, заморожена с 2023 года), линия кода видна только по git-хешу в конце строки.
  • Если конфигурация должна работать и с модулем ядра, и с userspace-движком (LXC, Docker, роутеры, macOS) — собирать I1I5 только из тегов {b, r, rc, rd, t}: наборы тегов реализаций расходятся, подробности в «Ловушках» выше.
  • Не копировать значения I1I5 из примеров: повторяющаяся у тысяч установок сигнатура работает против вас, чему посвящён отдельный разбор в обзорной заметке.

📚 См. также


🤖 Эти статьи открыты — можно обучать на них ИИ

При желании вы можете натренировать ИИ на наших статьях. Исходное форматирование и скачивание всего репозитория одним zip-архивом доступны в Forgejo: исходник этой заметки · весь репозиторий.