javascript

Подставляем TOTP в Chrome c помощью Yubikey

  • воскресенье, 14 сентября 2025 г. в 00:00:04
https://habr.com/ru/articles/942592/
Сгенерированная нейросетью картинка
Сгенерированная нейросетью картинка

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

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

Порядок действий:

  1. Поднять локальный сервер при старте компьютера

  2. Если расширение обнаружило на сайте нужное поле

  3. Запросить TOTP код и подставить его туда

  4. Отправить нужную HTML форму

Хочется работать с несколькими сайтами одновременно, поэтому нужен JSON конфиг (пример для github):

[
  {
    "hostname": "github.com", // домен
    "totpId": "github/risentveber", // идентификатор аккунта в yubikey
    "totpElementSelector": "#app_totp", // куда нужно подставить код
    "submitFormSelector": ".authentication form" // какую форму отправить после
  }
]

С выбором языка для расширений браузера не густо, поэтому и сервер решил писать на Javascript тоже. Код сервера прост как палка. Запрашиваем и отдаем нужный TOTP код по id аккаунта Yubikey, запуская ykman утилиту, ну и не забываем про CORS конечно же.

server.js
const http = require("http");
const url = require("url");
const { exec } = require("child_process");

const hostname = process.env.HOST || "localhost";
const port = process.env.PORT || 9999;

function sendJSON(res, data) {
  res.setHeader("Content-Type", "application/json");
  res.end(JSON.stringify(data));
}

function logInfo(msg) {
  console.log(`[${new Date().toISOString()}][INFO] ${msg}`);
}

function logError(msg) {
  console.error(`[${new Date().toISOString()}][ERROR] ${msg}`);
}

const server = http.createServer(
  { keepAlive: false, keepAliveTimeout: 0 },
  (req, res) => {
    res.setHeader("Access-Control-Allow-Origin", "*");
    if (req.method === "OPTIONS") {
      res.end();
      return;
    }
    res.setHeader("Connection", "close");

    const reqURL = url.parse(req.url, true);
    const totpId = reqURL.query.totpId;
    if (!totpId) {
      res.statusCode = 500;
      logError(`no totpId provided`);
      sendJSON(res, { error: "no totpId provided" });
      return;
    }

    exec(`ykman oath accounts code -s ${totpId}`, (error, stdout) => {
      if (error) {
        res.statusCode = 500;
        if (error.message.includes("Touch account timed out")) {
          res.statusCode = 408;
        }
        logError(`${totpId} ${error}`);
        sendJSON(res, { error: error.message });
        return;
      }
      logInfo(`success: ${totpId} ${stdout}`);
      sendJSON(res, { code: stdout.trim() });
    });
  },
);

server.listen(port, hostname, () => {
  logInfo(`server started at http://${hostname}:${port}`);
});

Клиентская часть тоже незатейлива. При наличии нужного элемента - запрашиваем TOTP код с сервера согласно конфигу, подставляем его и делаем submit на требуемую форму. Нюанс лишь в том, что необходимо учитывать переходы в single page application, которые и служат триггером поиска поля для TOTP. Также стоит избегать дублирования запроса, благо в логически однопоточном Javascript с этим все элементарно.

script.js
async function loadConfig() {
  const response = await fetch(chrome.runtime.getURL("configs.json"), {
    cache: "force-cache",
  });
  if (!response.ok) {
    throw new Error(`load config: ${response.status}`);
  }

  return await response.json();
}

var alreadyInProgress = false;

function substituteTotp() {
  loadConfig()
    .then((configs) => {
      const hostname = window.location.hostname;
      const config = configs.find((config) => config.hostname === hostname);
      if (!config) {
        return;
      }
      const totpElement = document.querySelector(config.totpElementSelector);
      if (!totpElement) {
        return;
      }
      if (alreadyInProgress) {
        console.log("already in progress, skipping");
        return;
      }
      alreadyInProgress = true;
      const totpId = config.totpId;
      chrome.runtime.sendMessage(chrome.runtime.id, { hostname, totpId });
      return fetch(`http://localhost:9999/get_code?totpId=${totpId}`)
        .then((r) => {
          if (r.status === 408) {
            throw new Error("yubikey: touch account timed out!");
          }
          if (!r.ok) {
            throw new Error("Ошибка HTTP: " + r.status);
          }
          return r.json();
        })
        .then((data) => {
          totpElement.value = data.code;
          if (config.submitFormSelector) {
            document.querySelector(config.submitFormSelector).submit();
          }
        });
    })
    .catch((e) => alert(`ошибка yubikey-extension: ${e}`))
    .then(() => (alreadyInProgress = false));
}

substituteTotp();
window.navigation.addEventListener("currententrychange", function (e) {
  substituteTotp();
});

Ну и чисто эстетический момент - при активации, хочется видеть обратную связь. Для этого нужен background worker, который и будет отправлять pop-up уведомление с красивой иконкой.

background.js
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  chrome.notifications.create({
    type: "basic",
    iconUrl: "icons/icon128.png",
    title: "Touch yubikey",
    message: `to get your code for ${request.hostname} with ${request.totpId}`,
  });
  sendResponse({});
  return true;
});
Выглядит все это примерно так
Выглядит все это примерно так

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

manifest.json
{
  "manifest_version": 3,
  "icons": {
    "48": "icons/icon48.png",
    "128": "icons/icon128.png"
  },
  "name": "yubikey chrome extension",
  "version": "1.0",
  "description": "Sets yubikey code in form",
  "permissions": ["activeTab", "notifications"],
  "host_permissions": ["http://localhost:9999/*"],
  "background": {
    "service_worker": "background.js"
  },
  "web_accessible_resources": [
    {
      "resources": ["configs.json"],
      "extension_ids": ["*"],
      "matches": ["*://*/*"]
    }
  ],
  "content_scripts": [
    {
      "js": ["script.js"],
      "matches": ["<all_urls>"]
    }
  ]
}

Чтобы установить расширение из исходников, достаточно сделать load unpacked (если меняете конфиг то нужно его обновлять каждый раз). Остается лишь добавить скрипт для старта сервера в автоматически запускаемые программы при старте (Login Items в случае MacOS). И все - можно наслаждаться результатом.

#!/bin/bash

dir=$(dirname -- "$0")
nohup node $dir/server.js &

P.S.
Это минимальный proof of concept, поэтому, в будущем можно сделать ряд доработок:

  • Запускать сервер с HTTPS

  • Добавить авторизацию через заголовок

  • Сделать конфигурацию настраиваемой через UI без перезагрузки расширения.

На данный момент основа безопасности - это требование физического касания к ключу yubikey. Весь код можно найти в github.com/risentveber/yubikey-chrome-extension, буду рад вашим pull-requests.

P.P.S.
Также веду свой микро канал, в формате любопытных заметок.