
В продолжение предыдущей статьи решил написать эту. Тем более, что мне порядком надоело подставлять TOTP коды на разных сайтах и особенно каждый день на работе.
Итак, дано: сайт в браузере, где нужно подставить код после ввода логина и пароля. Правилами безопасности в расширениях Chrome запрещено обращаться к устройствам подключенным к компьютеру напрямую. Но как же работают всякие расширения для цифровых подписей вроде Крипто Про? Они обращаются к локальному серверу, который и делает всю грязную работу за них.
Порядок действий:
Поднять локальный сервер при старте компьютера
Если расширение обнаружило на сайте нужное поле
Запросить TOTP код и подставить его туда
Отправить нужную 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 fs = require('fs'); 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 allowedOrigins = JSON.parse(fs.readFileSync('configs.json', 'utf8')).map(item => "https://" +item.hostname) const server = http.createServer( { keepAlive: false, keepAliveTimeout: 0 }, (req, res) => { const origin = req.headers.origin; if (allowedOrigins.includes(origin)) { res.setHeader("Access-Control-Allow-Origin", 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; } const totpIdSanitized = totpId.replace(/[^0-9a-zA-Z@./_\-]/g, "") exec(`ykman oath accounts code -s ${totpIdSanitized}`, (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": ["<all_urls>"] } ], "content_scripts": [ { "js": ["script.js"], "matches": ["<all_urls>"] } ] }
Чтобы установить расширение из исходников, достаточно сделать load unpacked (если меняете конфиг то нужно его обновлять каждый раз). Остается лишь добавить скрипт для старта сервера в автоматически запускаемые программы при старте (Login Items в случае MacOS). И все - можно наслаждаться результатом.
#!/bin/bash dir=$(dirname -- "$0") cd $dir nohup node ./server.js &
P.S.
Это минимальный proof of concept, поэтому, в будущем можно сделать ряд доработок:
Запускать сервер с HTTPS
Добавить авторизацию через заголовок
Сделать конфигурацию настраиваемой через UI без перезагрузки расширения.
На данный момент основа безопасности - это требование физического касания к ключу yubikey. Весь код можно найти в github.com/risentveber/yubikey-chrome-extension, буду рад вашим pull-requests.
P.P.S.
Также веду свой микро канал, в формате любопытных заметок.
