Сегодня мы на Product Hunt! Оставить комментарий

Вышла версия Authenticator 1.16.0. Что нового

Как это работает

Технический обзор для тех, кто проверяет безопасность. Всё изложенное сверяется с исходным кодом — включая то, что нас не красит: мы это называем, а не опускаем.

Архитектура

Это расширение Chrome на Manifest V3, которое генерирует коды TOTP локально с помощью OTPAuth. Генерация кодов, хранение аккаунтов и их шифрование обходятся без бэкенда, и учётных записей пользователей нет. У нас есть один сервер, api.authenticator.sh, — он нужен для бесплатного тарифа и Pro и описан ниже; он никогда не получает ни секретов, ни названий аккаунтов, ни названий сервисов, ни кодов.

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

Криптография

Защита паролем необязательна и по умолчанию выключена. Когда она включена:

Шифр
AES-256-GCM, случайный 96-битный IV для каждой записи и каждой перезаписи
Выработка ключа
PBKDF2-HMAC-SHA256, 600 000 итераций, случайная 128-битная соль (по рекомендациям OWASP)
Мастер-ключ
256 бит, из crypto.getRandomValues, генерируется один раз; пароль лишь оборачивает его
Отпечатки
HMAC-SHA256 на подключе, выведенном через HKDF; нужны для сведения записей между устройствами
Хранение разблокированного ключа
chrome.storage.session — только в памяти, никогда не пишется на диск, очищается при закрытии браузера, недоступен из content-скриптов
Автоблокировка
При каждом открытии / через 5, 15, 60 минут простоя / до закрытия браузера
Реализация
Только WebCrypto. Ни один примитив не написан вручную, никакая криптобиблиотека не поставляется в сборке.

Почему ключей два уровня

Данные никогда не шифруются ключом, выведенным из пароля, напрямую. Записи шифрует случайный мастер-ключ; вывод PBKDF2 только оборачивает этот мастер-ключ. Отсюда два следствия, и оба важны:

  • Смена пароля переписывает 32 байта вместо перешифрования каждой записи, каждой резервной копии и каждого экспорта. Именно на массовой перезаписи хранилища и теряют данные.
  • 160-битный код восстановления может независимо обернуть тот же мастер-ключ, поэтому забытый пароль не приговор. Код показывается один раз, его нужно ввести обратно для подтверждения, и он меняется после каждого использования. На диск ничего не пишется, пока пользователь его не подтвердил.

Что шифруется, а что нет

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

Говорим прямо, потому что для вашей оценки это существенно:

  • Если защита паролем выключена, аккаунты хранятся незашифрованными и, если включена синхронизация Chrome, реплицируются через собственный аккаунт Google пользователя. К нам они не попадают никогда, но Google их хранит. Синхронизацию можно отключить — это заодно удалит и то, что там уже есть.
  • История использования по сайтам — какой аккаунт применяется на каком домене — хранится локально и, пока собирается, хранилищем не защищена. Включение защиты паролем её удаляет; её также можно отключить отдельно.

Модель угроз

Защищает от

  • Кражи или копирования каталога профиля браузера
  • Вредоносных программ-стилеров, выгружающих данные браузера целиком
  • Физического доступа к оставленному без присмотра компьютеру
  • Попадания записей аккаунтов в Google через синхронизацию Chrome
  • Потери или кражи файла резервной копии (экспорт под паролем)

Не защищает от

  • Вредоносного кода, работающего от имени пользователя при разблокированном хранилище
  • Кейлоггера, перехватывающего пароль в момент ввода
  • Скомпрометированного браузера или операционной системы
  • Взлома самих сервисов, для которых предназначены коды
  • Незашифрованного экспорта, который вы сделали сами, — обычный JSON, ссылки otpauth:// или CXF прочитает любой, кто откроет файл

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

Разрешения

Манифест объявляет пять разрешений и ни одного host-разрешения:

storage
Аккаунты и настройки в локальном хранилище, разблокированный ключ в session storage и необязательная копия для синхронизации
activeTab
Читает имя хоста активной вкладки, чтобы подсветить подходящий аккаунт, и делает снимок вкладки, когда пользователь сканирует QR-код с экрана
contextMenus
Добавляет пункт «Вставить код 2FA» в контекстное меню — и только на редактируемых полях страниц http(s)
scripting
Внедряет ту единственную функцию, которая кладёт код в поле, — для одной вкладки и одного вызова, только после выбора этого пункта меню
sidePanel
Открывает приложение в боковой панели Chrome, если выбран этот режим; доступа к страницам не даёт

В расширении нет content-скриптов, нет разрешений tabs, cookies и webRequest и нет web-accessible-ресурсов. Ничего нашего на странице не существует, пока вы не попросите там код; то, что выполняется потом, внедряется для одной этой вкладки и одного этого вызова, по разрешению, которое даёт ваш собственный клик, и не оставляет после себя ничего.

Камера

Сканирование QR-кода камерой работает на странице расширения, открытой во вкладке, а не во всплывающем окне: popup уничтожается при потере фокуса, а запрос разрешения его как раз отбирает. Для этого не нужно разрешение в манифесте: используется штатный запрос камеры браузера, который выдаётся на origin расширения и отзывается в настройках сайта, и он никогда не появляется, пока пользователь не откроет сканер. Видео декодируется локально, никуда не передаётся и не сохраняется.

Бесплатный тариф, Pro и ежедневный запрос к серверу

Начиная с 1.14.0 расширение раз в сутки связывается с api.authenticator.sh — при запуске браузера или уже после того, как окно расширения отрисовано, так что показ кода этот запрос никогда не задерживает. Запрос — закрытый список полей: его собирает одна функция в src/billing/sync.ts, а тест следит, чтобы в нём не появилось ничего сверх этого списка:

iid
Случайный идентификатор, созданный этой установкой. Не связан ни с аккаунтом Google, ни с адресом электронной почты, ни с браузером — если только пользователь не купит Pro: тогда запись о покупке связывает установку, из которой он купил, и любую установку, где активен лицензионный ключ, с адресом электронной почты, указанным при оплате
ver, ui_lang, browser_lang
Версия расширения, язык интерфейса, язык браузера
installed_at, legacy, legacy_src
Когда появилась установка и была ли она сделана до введения биллинга
accounts, groups
Сколько в ней аккаунтов и в скольких они группах — числа, но никогда не какие именно аккаунты и не названия групп
vault, sync, open_mode, view_mode, style, dark, os
Включены ли защита паролем и синхронизация и как открывается расширение, как показан список аккаунтов (обычный, компактный или со скрытыми кодами), а также выбранный стиль и тема — светлая или тёмная, и операционная система (Windows, macOS, Linux, ChromeOS)
pid, lid, has_key, key_hint
Какие подписанные политика и лицензия сейчас есть у установки, есть ли у неё уже лицензионный ключ и последние четыре символа этого ключа
opens
Сколько раз расширение открывали в каждый из дней с прошлого запроса (не больше чем за последние 14 дней) — число за день, и ничего о том, что при этом смотрели
events
Накопленные события биллинга из закрытого списка: достигнут лимит, экран Pro (paywall) показан или закрыт, выбран тариф, была попытка оплаты или ввода ключа

Никогда не передаются: секреты, названия аккаунтов, названия сервисов (issuer), коды и сайты, на которых они используются. Страну сервер определяет по запросу на своём edge-узле и IP-адрес не сохраняет. Страница удаления получает тот же идентификатор установки в параметре ?i=: она сообщает серверу, что установка удалена, и связывает с ней необязательный ответ на опрос.

Для этого в chrome.storage.local хранятся идентификатор установки, последние подписанные политика и лицензия, лицензионный ключ этой установки (введённый вручную или полученный после покупки в ней), счётчики открытий по дням, пока их не получил сервер, и события в очереди — никаких данных аккаунтов. Пока включена синхронизация, признак того, что установка сделана до введения биллинга, и этот лицензионный ключ попадают ещё и в Chrome Sync — чтобы другие профили Chrome этого пользователя их подхватили и сами включили Pro; если синхронизацию выключить, оба удаляются из Chrome Sync, а при повторном включении возвращаются.

Что приходит в ответ и как расширение с этим обращается:

Токены
Политика (лимит аккаунтов бесплатного тарифа) и лицензия (Pro) — это JWS с подписью ES256 (ECDSA P-256)
Проверка
WebCrypto, по открытым ключам, вшитым в сборку (src/billing/keys.ts). Сборка для Chrome Web Store доверяет только рабочему (production) ключу, а проверка при сборке отклоняет сборку, в которой есть любой другой
Офлайн
Лимит и лицензия проверяются локально, по последним подписанным токенам. Пока подписанная политика не получена, ничего не ограничивается — недоступный сервер никогда не может никого заблокировать
Что может лимит
Не дать добавить аккаунт — и ничего больше. Он не может скрыть, заблокировать, удалить или изменить аккаунт, а также помешать кодам, экспорту, резервным копиям, восстановлению или синхронизации
Покупки
Stripe Checkout в новой вкладке; продавец (merchant of record) — Link. Email покупателя приходит к нам от Stripe, чтобы выдать лицензионный ключ и восстановить его; ключи хранятся в виде хеша плюс зашифрованная копия, чтобы потерянный ключ можно было отправить снова
Лицензионный ключ
Сам ключ приходит в ответе на ежедневный запрос только той установке, в которой купили Pro, и только пока четыре символа, которые она сообщает в key_hint, не совпадут с этим ключом, — так ключ, купленный позже, заменяет прежний; установке, в которую ключ ввели вручную, он не отправляется никогда

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

Сетевые запросы

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

ХостКогдаПередаёт данные пользователя?
time.akamai.com
timeapi.io
cloudflare.com
Проверка расхождения часов, кэшируется; если ни один источник недоступен, об этом сказано в настройках. При сбитых часах TOTP ломается.Нет
authenticator.shСтраница приветствия при установке; страница обратной связи при удалении — с идентификатором установки в адресе; страницы помощи и поддержки, когда их открывают из окна расширения.Идентификатор установки — только на странице удаления; в остальном нет (обычные логи веб-сервера)
api.authenticator.shБиллинговый запрос раз в сутки; запросы оплаты, лицензии и портала, когда пользователь покупает Pro, вводит ключ, восстанавливает покупку или управляет подпиской.Поля, перечисленные выше, — но никогда данные аккаунтов. Лицензионный ключ или адрес электронной почты — только если пользователь сам их вводит
checkout.stripe.com
billing.stripe.com
Stripe Checkout и клиентский портал, открываются во вкладке, когда пользователь покупает Pro или управляет подпиской.То, что покупатель там вводит. Действуют собственные условия Stripe и Link
chromewebstore.google.comСтраница в магазине, открывается во вкладке только по клику: форма отзыва — из предложения оценить.Нет (для Google это обычный заход на страницу магазина)

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

Как проверить то, что мы публикуем

Ничего из перечисленного не нужно принимать на веру. Расширение с открытым исходным кодом, и каждый релиз воспроизводим:

  1. Скачайте опубликованный .crx из Chrome Web Store и распакуйте его
  2. Переключитесь на соответствующий git-тег
  3. Выполните npm ci && npm run build на Node 20 LTS
  4. Сравните получившийся dist/ с распакованным пакетом

SHA-256 для каждого файла в собранном dist/ публикуется с каждым релизом на GitHub в виде SHA256SUMS-v<version>.txt, в формате sha256sum — так что шаг 4 сводится к одному запуску sha256sum -c. Расхождения должны ограничиваться порядком файлов внутри архивов и пробелами в минифицированном выводе на разных патч-версиях Node.

Зависимостей в рантайме намеренно мало — OTPAuth, React, jsQR, Zustand, Framer Motion и Lucide — и все они зафиксированы закоммиченным lock-файлом. SBOM в формате CycloneDX предоставляем по запросу.

Смотреть исходный код

Чего у нас нет

Мы небольшой независимый разработчик. Если что-то из перечисленного для вашего процесса обязательно, лучше узнать об этом сейчас, а не на третьей неделе оценки:

  • Нет сертификации SOC 2, ISO 27001 или аналогичной
  • Независимый пентест до сих пор не заказывался
  • Нет платной программы bug bounty

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

Как с нами связаться

Сообщения об уязвимостях и вопросы по безопасности: security@authenticator.sh. Политика раскрытия, границы применимости, сроки ответа и условия safe harbour — на странице политики безопасности; что хранится и что покидает устройство — в политике конфиденциальности.

Мы готовы заполнить письменную анкету оценки поставщика или разобрать код вместе с вашей командой безопасности.

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