Подставляем TOTP в Chrome c помощью Yubikey
- воскресенье, 14 сентября 2025 г. в 00:00:04
В продолжение предыдущей статьи решил написать эту. Тем более, что мне порядком надоело подставлять 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 конечно же.
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 с этим все элементарно.
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 уведомление с красивой иконкой.
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_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.
Также веду свой микро канал, в формате любопытных заметок.