Перейти к содержанию

Web3: одноразовый nonce и атомарное потребление

Оглавление курса «Web3: вход, смарт-аккаунты и индексатор» · Урок 3 из 30.

Одноразовый nonce защищает вход только тогда, когда сервер использует его ровно один раз. Если две параллельные проверки обе увидят «не использован», одна подпись создаст две сессии. В этом уроке реализуем атомарное потребление challenge и рассмотрим границу учебного хранилища в памяти. Результат — проверка подписи не заканчивается выдачей сессии без успешного одноразового перехода.

Предполагается запись challenge предыдущего урока. Подпись проверяется отдельным асинхронным шагом, который введём позже. Здесь определяем порядок серверной операции. Node-программа, база данных и проверки конкурентности не запускались; результаты являются ожидаемыми по модели исполнения и атомарному правилу.

Сначала проверка, затем одноразовый переход

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

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

Проверка срока повторяется при потреблении. Подпись могла проверяться некоторое время, и challenge мог истечь между начальным чтением и окончанием. Нельзя использовать старый успешный результат проверки времени как вечное разрешение. Серверное время является источником политики, а переданное клиентом поле expiry сопоставляется с выданной записью.

Учебное хранилище одного процесса

В одном Node-процессе синхронная функция может проверить и удалить элемент карты без await между этими действиями. Это демонстрирует атомарную границу внутри одного потока выполнения, но не решает задачу нескольких серверных процессов.

export function createChallengeStore() {
  const records = new Map();
  return {
    add(record) {
      if (records.has(record.nonce)) throw new Error('Повтор nonce');
      records.set(record.nonce, { ...record });
    },
    get(nonce) { return records.get(nonce) ?? null; },
    consume(nonce, flowId, now = Date.now()) {
      const record = records.get(nonce);
      if (!record || record.flowId !== flowId
          || Date.parse(record.expiresAt) <= now) return false;
      records.delete(nonce);
      return true;
    },
  };
}

flowId создаётся сервером и связывает challenge с конкретным браузерным потоком. Он не является адресом Ethereum и не заменяет подпись. При выдаче challenge этот идентификатор сохраняется вместе с записью, а при проверке сервер получает его из своего механизма потока, не доверяя произвольному JSON-полю посетителя.

Функция get здесь возвращает ссылку на внутренний объект, поэтому реальная библиотека лучше возвращает копию или неизменяемое представление. Иначе вызывающий код мог бы изменить expiresAt и разрушить правило. Этот случай показывает, что даже маленькому хранилищу нужен понятный договор владения данными.

Почему await создаёт гонку

Рассмотрим два запроса A и B. Оба прочитали одинаковый challenge. Каждый отдельно проверяет подпись через асинхронный источник. A завершает первым и потребляет nonce. B завершает позже, но его криптографический результат тоже правильный. Именно повторный атомарный переход должен отказать B; успешная подпись не отменяет одноразовость.

Проверка вида if (store.get(nonce)) { await createSession(); store.delete(nonce); } неправильна: между чтением и удалением появляется асинхронный промежуток. Другой запрос может пройти ту же ветвь. Поэтому потребление выполняется до выдачи сессии и возвращает результат перехода. Если создание сессии затем не удалось, применяется отдельная политика восстановления, а nonce не оживает незаметно.

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

Вариант для базы данных

В PostgreSQL одноразовый переход можно выразить условным удалением с возвращением записи. Это исходник будущего серверного адаптера, не выполненная команда и не готовый полный проект базы.

DELETE FROM login_challenges
WHERE nonce = $1
  AND flow_id = $2
  AND expires_at > CURRENT_TIMESTAMP
RETURNING address, message;

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

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

Срок и очистка

Истёкшие challenge удаляются фоновым процессом или запросом обслуживания. Ограничение объёма также важно: бесконечная выдача nonce может исчерпать память. Частота выдачи, размер message и допустимые адреса проверяются до сохранения. Эти ограничения защищают доступность, но не заменяют проверку подписи и одноразовый переход.

Если сервер перезапустился и учебная карта исчезла, старое сообщение не принимается. Человек начинает новый вход. Нельзя считать отсутствие записи разрешением проверить подпись без nonce: это убрало бы ключевую защиту. Хранилище является частью процесса аутентификации, а не необязательным кешем.

Для упражнения нарисуйте два правильных параллельных запроса и одну неверную подпись. Ожидается только один успешный переход для первых двух, а неверная подпись не создаёт сессию. Затем рассмотрите два независимых серверных процесса с картами: локальное правило уже не обеспечивает общую одноразовость. Итог — nonce является состоянием сервера, а не просто строкой сообщения. Требования описаны в ERC-4361.

Результат потребления и выдача cookie

Атомарный переход возвращает только необходимые сведения проверенного challenge. Сервер не запрашивает адрес ещё раз из клиентского JSON после удаления: иначе успешная проверка A могла бы закончиться сессией B. Адрес и назначение берутся из сохранённой записи, сопоставленной с подписанными байтами.

При отсутствии возвращённой строки SQL-адаптер не пытается восстановить удалённый challenge по сообщению посетителя. Такая автоматическая вставка вернула бы одноразовое значение в пригодное состояние и разрушила правило. Безопасный путь — новая попытка входа с новым nonce по явному действию пользователя. Неизвестность технического результата также фиксируется отдельно, а не решается повторным использованием прежней подписи.

Оглавление серии