Авторизация
Регистрация

Напомнить пароль

Аппаратный менеджер паролей с шифрованием. Коммерческий проект

Не скажу что когда-то задумывался о таком, но после отправки клиенту захотел себе :) Встречайте, аппаратное хранилище авторизационных данных с шифрованием, Wi-fi веб-сервером для смены паролей и цветастой индикацией, умещающееся в кармане. Код и модели выложены в открытый доступ под лиценизей GPL 3.0 — копирование, улучшение и продажа приветствуются, но только если код и модели вашего проекта будут открыты общественности!

С заказчиком оперативно обговорили ТЗ и сошлись на хранилище-флешке с тремя кнопками — каждая должна соответствовать своему паролю. Что-то вроде такого, но поменьше кнопок.


Чтобы не плодить дополнительных программ для смены пароля (а смена — фича обязательная), было решено реализовать ее с помощью веб-сервера через точку доступа. Также заказчик попросил, чтобы его пароли не лежали в открытом виде. Последняя просьба прозвучала немного странно, ибо нажатие кнопки вводит пароль в совершенно открытом виде… Но ладно, шифрование так шифрование. Пусть будет AES-256.


Итого я решил делать устройство на базе ESP32-S3 Mini. Это компактная плата с поддержкой аппаратной эмуляции клавиатуры через USB, а еще туда встроен RGB-светодиод на протоколе NeoPixel. Это означает, что заморачиваться с индикацией не придется вовсе.


По аппаратной компоновке я изначально решил, что разводить плату не стоит — у нас пара кнопок всего на схеме. Но и на проводках лепить я их не стал. Вместо этого припаял кнопки как есть к отладочной плате.


Выставив один из пинов кнопки в режим OUTPUT LOW, можно реализовать «программную» землю. То есть мне даже не нужно подключать кнопки к земле — а значит и выводить лишние провода.

Третья кнопка уже встроена на плате и подключена к пину 0. Она выполняет две функции. Если зажать ее и включить питание, плата войдет в режим прошивки. А в обычном режиме это такая же кнопка ввода паролей. Также пригодился RGB-светодиод на пине 21.


Когда пришло время разводить USB, я понял, что контакты никуда, кроме Type-C на плате, не выведены. Поразмыслив, я решил просто сделать переходник. Благо у меня валялись совершенно бесполезные Type-C штекеры с резистором на 56 кОм. По-русски говоря это означает, что на выход питания они не годятся (а покупались за этим давным-давно), а вот на вход работают отлично!


Что касается корпуса, он получился трехсоставным (не считая удлинителя кнопки) — основной модуль с платой закрывается двумя половинками, в него вставляется монолитный переходник. Светодиод вывел с помощью капли термоклея.


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

За счет переходника это самостоятельное устройство вроде флешки, но при желании можно подключить через удлиняющий провод Type-C. Устройство становится еще более компактным.


И, конечно, пару слов сказать о прошивке. Все относительно стандартно — я использовал USB и USBHIDKeyboard для клавиатуры, Preferences, nvs_flash и aes для сохранения пароля зашифрованным; GyverButton — для кнопок; WebServer, WiFi, DNSServer — для странички смены паролей.

Полный листинг кода
#include <Arduino.h>
#include "USB.h"
#include "USBHIDKeyboard.h"
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <GyverButton.h>
#include "mbedtls/aes.h"
#include "nvs_flash.h"

// ==========================================
// НАСТРОЙКИ ПИНОВ И ЖЕЛЕЗА
// ==========================================
const int BTN_PINS[3] = {0, 6, 7};
const int GND_PINS[2] = {4, 9};
const int RGB_PIN = 21;

GButton btn1(BTN_PINS[0]);
GButton btn2(BTN_PINS[1]);
GButton btn3(BTN_PINS[2]);

USBHIDKeyboard Keyboard;
WebServer server(80);
DNSServer dnsServer;
const byte DNS_PORT = 53;
Preferences prefs;

bool isConfigMode = false;
unsigned long configStartTime = 0;

// ==========================================
// УПРАВЛЕНИЕ RGB
// ==========================================
void setRGB(uint8_t r, uint8_t g, uint8_t b) {
  neopixelWrite(RGB_PIN, r, g, b);
}

// ==========================================
// КРИПТОГРАФИЯ (AES-256 CTR)
// ==========================================
uint8_t aesKey[32];
uint8_t aesIV[16];

void initCrypto() {
  prefs.begin("crp", false); // Короткое имя пространства имен
  if (prefs.getBytesLength("key") != 32) {
    esp_fill_random(aesKey, 32);
    esp_fill_random(aesIV, 16);
    prefs.putBytes("key", aesKey, 32);
    prefs.putBytes("iv", aesIV, 16);
  } else {
    prefs.getBytes("key", aesKey, 32);
    prefs.getBytes("iv", aesIV, 16);
  }
  prefs.end();
}

void savePassword(int index, String plain) {
  if (plain.length() == 0) return;

  mbedtls_aes_context aes;
  mbedtls_aes_init(&aes);
  mbedtls_aes_setkey_enc(&aes, aesKey, 256);

  unsigned char iv_copy[16];
  memcpy(iv_copy, aesIV, 16);

  size_t nc_off = 0;
  size_t len = plain.length();
  unsigned char stream_block[16];
  unsigned char* encrypted = new unsigned char[len];

  mbedtls_aes_crypt_ctr(&aes, len, &nc_off, iv_copy, stream_block, (const unsigned char*)plain.c_str(), encrypted);
  mbedtls_aes_free(&aes);

  prefs.begin("pwd", false);
  String key = "p" + String(index);
  prefs.putBytes(key.c_str(), encrypted, len);
  prefs.end();

  delete[] encrypted;
}

String loadPassword(int index) {
  prefs.begin("pwd", true);
  String key = "p" + String(index);
  size_t len = prefs.getBytesLength(key.c_str());
  if (len == 0) {
    prefs.end();
    return ""; // Если в памяти ничего нет
  }

  unsigned char* encrypted = new unsigned char[len];
  prefs.getBytes(key.c_str(), encrypted, len);
  prefs.end();

  mbedtls_aes_context aes;
  mbedtls_aes_init(&aes);
  mbedtls_aes_setkey_enc(&aes, aesKey, 256);

  unsigned char iv_copy[16];
  memcpy(iv_copy, aesIV, 16);

  size_t nc_off = 0;
  unsigned char stream_block[16];
  unsigned char* decrypted = new unsigned char[len + 1];

  mbedtls_aes_crypt_ctr(&aes, len, &nc_off, iv_copy, stream_block, encrypted, decrypted);
  mbedtls_aes_free(&aes);

  decrypted[len] = '\0';
  String result = String((char*)decrypted);

  delete[] encrypted;
  delete[] decrypted;
  return result;
}

// ==========================================
// ВЕБ-СЕРВЕР И CAPTIVE PORTAL
// ==========================================
void handleRoot() {
  String html = R"=====(
<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Менеджер паролей</title>
<style>
  body { font-family: 'Segoe UI', Tahoma, sans-serif; background: #1a1a2e; color: #fff; padding: 20px; display: flex; justify-content: center; margin: 0;}
  .card { background: #16213e; padding: 20px; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.8); width: 100%; max-width: 420px; }
  h2 { text-align: center; color: #e94560; margin-bottom: 20px; text-transform: uppercase; letter-spacing: 1px;}
  .group { margin-bottom: 20px; background: #0f3460; padding: 15px; border-radius: 8px; }
  .wifi-group { background: #e9456022; border: 1px solid #e94560; }
  label { display: block; margin-bottom: 8px; font-weight: bold; font-size: 14px; color: #ccc;}
  input { width: 100%; padding: 12px; margin-bottom: 12px; border: none; border-radius: 5px; box-sizing: border-box; background: #222; color: #0f0; font-family: monospace; font-size: 16px; outline: none;}
  button { width: 100%; padding: 12px; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; transition: 0.2s; }
  .btn-gen { background: #e94560; color: #fff; }
  .btn-gen:hover { background: #d83a50; }
  .btn-save { background: #4caf50; color: #fff; font-size: 18px; padding: 16px; margin-top: 10px; text-transform: uppercase;}
  .btn-save:hover { background: #45a049; }
  .note { font-size: 12px; color: #888; text-align: center; margin-top: 15px; line-height: 1.5; }
</style>
<script>
  function g(id) {
    const c = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~=";
    let p = ""; for(let i=0; i<16; i++) p += c[Math.floor(Math.random()*c.length)];
    document.getElementById(id).value = p;
  }
</script>
</head><body>
<div class="card">
  <h2>Настройки ключа</h2>
  <form action="/save" method="POST">

    <div class='group wifi-group'>
      <label> ПАРОЛЬ ОТ ЭТОЙ WI-FI СЕТИ</label>
      <input type='text' name='p0' id='p0' placeholder='Оставьте пустым для сохранения текущего'>
      <div style="font-size:11px; color:#aaa; margin-top:-5px;">Минимум 8 символов.</div>
    </div>
)=====";

  for (int i = 1; i <= 3; i++) {
    html += "<div class='group'><label>КНОПКА " + String(i) + "</label>";
    html += "<input type='text' name='p" + String(i) + "' id='p" + String(i) + "' placeholder='Введите новый или оставьте пустым'>";
    html += "<button type='button' class='btn-gen' onclick=\"g('p" + String(i) + "')\">Сгенерировать</button></div>";
  }

  html += R"=====(
    <button type="submit" class="btn-save">Сохранить в память</button>
  </form>
  <div class="note">⚠️ Текущие пароли скрыты.</div>
</div></body></html>
  )=====";
  server.send(200, "text/html", html);
}

void handleSave() {
  String p0 = server.arg("p0");
  String p1 = server.arg("p1");
  String p2 = server.arg("p2");
  String p3 = server.arg("p3");

  if (p0.length() >= 8) savePassword(0, p0);
  if (p1.length() > 0) savePassword(1, p1);
  if (p2.length() > 0) savePassword(2, p2);
  if (p3.length() > 0) savePassword(3, p3);

  setRGB(100, 80, 0);

  String html = "<!DOCTYPE html><html><body style='background:#1a1a2e; color:#0f0; font-family:sans-serif; text-align:center; padding-top:50px;'>";
  html += "<h2>Успешно сохранено!</h2><p>Устройство перезагружается...</p></body></html>";

  server.send(200, "text/html", html);
  delay(1500);
  setRGB(0, 0, 0);
  ESP.restart();
}

void handleNotFound() {
  server.sendHeader("Location", String("http://") + WiFi.softAPIP().toString(), true);
  server.send(302, "text/plain", "");
}

// ==========================================
// СТАРТ УСТРОЙСТВА
// ==========================================
void setup() {
  // На всякий случай оставляем защиту от битой флешки
  esp_err_t err = nvs_flash_init();
  if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
    nvs_flash_erase();
    nvs_flash_init();
  }

  for (int i = 0; i < 2; i++) {
    pinMode(GND_PINS[i], OUTPUT);
    digitalWrite(GND_PINS[i], LOW);
  }

  setRGB(50, 0, 100);
  delay(400);
  setRGB(0, 0, 0);

  btn1.setTimeout(3000);
  btn2.setTimeout(3000);
  btn3.setTimeout(3000);

  initCrypto();
  Keyboard.begin();
  USB.productName("PassKey HID"); // Заставляем ПК увидеть новое устройство
  USB.manufacturerName("WireRod");
  USB.begin();
}
// ==========================================
// ГЛАВНЫЙ ЦИКЛ
// ==========================================
void loop() {
  if (isConfigMode) {
    dnsServer.processNextRequest();
    server.handleClient();
    if (millis() - configStartTime > 300000) ESP.restart();
    return;
  }

  btn1.tick();
  btn2.tick();
  btn3.tick();

  bool c1 = btn1.isClick();
  bool c2 = btn2.isClick();
  bool c3 = btn3.isClick();

  // --- КОРОТКИЕ НАЖАТИЯ (Ввод пароля) ---
  if (c1 || c2 || c3) {
    setRGB(0, 100, 0);

    String p = "";
    if (c1) p = loadPassword(1);
    else if (c2) p = loadPassword(2);
    else if (c3) p = loadPassword(3);

    if (p == "") {
      Keyboard.print("[ERROR: MEMORY IS EMPTY]");
    } else {
      Keyboard.print(p.c_str());
    }

    delay(50);
    setRGB(0, 0, 0);
  }

  bool h1 = btn1.isHold();
  bool h2 = btn2.isHold();
  bool h3 = btn3.isHold();

  // --- ДОЛГОЕ НАЖАТИЕ ---
  if (h1 || h2 || h3) {

    // ПРОВЕРКА НА ПАНИК-КНОПКУ (Зажаты ли ВСЕ ТРИ кнопки физически?)
    if (btn1.state() && btn2.state() && btn3.state()) {

      setRGB(255, 0, 0); // Врубаем КРАСНЫЙ

      // Полностью форматируем раздел с паролями
      prefs.begin("pwd", false);
      prefs.clear();
      prefs.end();

      // И удаляем крипто-ключи (при следующей загрузке сгенерируются новые)
      prefs.begin("crp", false);
      prefs.clear();
      prefs.end();

      delay(2000); // Горим красным 2 секунды, чтобы юзер понял, что всё удалено
      setRGB(0, 0, 0);
      ESP.restart(); // Перезагружаемся в чистом виде

    } else {

      // ОБЫЧНЫЙ РЕЖИМ WI-FI (Зажата одна кнопка)
      setRGB(0, 50, 150);

      String apPass = loadPassword(0);
      if (apPass.length() < 8) apPass = "12345678";

      WiFi.softAP("MyPassKey", apPass.c_str());
      dnsServer.start(DNS_PORT, "*", WiFi.softAPIP());

      server.on("/", handleRoot);
      server.on("/save", HTTP_POST, handleSave);
      server.onNotFound(handleNotFound);
      server.begin();

      isConfigMode = true;
      configStartTime = millis();
    }
  }
}


Для чего DNSServer, спросите вы? Как оказалось, можно упростить вход на страничку паролей, если перенаправлять запросы ОС на специализированный DNS-сервер, как это делают всякие роутеры. Теперь не нужно вбивать айпишник в адресную строку, можно просто нажать на всплывающее предупреждение. Очень удобно.


Сама страничка содержит поля смены пароля Wi-Fi и пользовательских учетных данных.



Реализован режим сброса — зажать все три кнопки. Пароль WiFi при этом сбрасывается на стандартный, а остальные стираются из памяти.

За счет индикации взаимодействие с устройством становится более интуитивным. Подключение устройства к ПК.


Ввод пароля.


При активации смены пароля светодиод начинает постоянно гореть синим.


В принципе, и я, и заказчик устройством остались довольны. Думаю сделать нечто похожее и себе, но с биометрической авторизацией. В текущем виде устройство чуть получше листка с паролем, наклеенным на монитор. Полный код и файлы для печати (и для редактирования) доступны в репозитории.
Добавить в избранное
+21 +24
свернутьразвернуть
Комментарии (10)
RSS
+
avatar
0
  • E155-2
  • 25 августа 2026, 22:30
Комментарий ожидает проверки администрацией сайта. Подробнее...
+
avatar
0
  • andy077
  • 25 августа 2026, 23:38
Ни один безопасный компутер вашу феньку в себя не допустит.
+
avatar
+2
  • Latte
  • 25 августа 2026, 23:40
Также заказчик попросил, чтобы его пароли не лежали в открытом виде. Последняя просьба прозвучала немного странно, ибо нажатие кнопки вводит пароль в совершенно открытом виде…
Один пароль не дискредитирует все хранилище в случае утери сего девайса. Но для этого надо заморачиваться с eFuse, как минимум, а в такой реализации, как я понял, все сливается с флешки обычным программатором вместе с ключом. Так уж аес тут реально без толку, только еспшку греть.
Думаю сделать нечто похожее и себе, но с биометрической авторизацией.
Может, лучше юбикей… Можно еще попридираться к коду в целом, но тут смотря что понимать под коммерцией — на деле оказывается, что это единичный заказ на одного клиента.
+
avatar
+2
  • Omnicorn
  • 26 августа 2026, 00:01
Но для этого надо заморачиваться с eFuse, как минимум, а в такой реализации, как я понял, все сливается с флешки обычным программатором вместе с ключом.
В целом, да. Но технически пароль все же хранится зашифрованным, верно? :)
Может, лучше юбикей…
Это не столь интересно. Я тут почитал про сенсор отпечатков, и, оказывается, для защищенной реализации нужно пилить свой парсер отпечатков, так как дешевые датчики работают по уморительно незащищенному протоколу. Так что проект получится занимательный, с фильтрами Габара, может полярным FFT, или нейронкой.
Можно еще попридираться к коду в целом, но тут смотря что понимать под коммерцией — на деле оказывается, что это единичный заказ на одного клиента.
В любом случае заказ есть заказ. А код да, не идеален. Но я решил оставить AES как заготовку на будущее. Было интересно его потыкать в коде.
+
avatar
0
  • nsn
  • 26 августа 2026, 00:04
Один пароль не дискредитирует все хранилище в случае утери сего девайса.
Что-то непонятно, какое всё хранилище?
+
avatar
+1
  • Zolg
  • 26 августа 2026, 00:23
если нужна хоть сколько-то безопасная аутентификация, то github.com/polhenarejos/pico-fido (оно же www.picokeys.com/)
Там тоже не все идеально, но во-первых это аппаратные ограничения, от которых без использования специализированных чипов не избавиться (да и с использованием — не на 100%), во-вторых все же ГОРАЗДО безопасней сабжового велосипеда
+
avatar
0
  • Omnicorn
  • 26 августа 2026, 00:49
Да я и не заявлял какую-то особую безопасность. Вставляешь прибор — нажимаешь на кнопку — вводится пароль. Тут уж вопрос, попадет она в чужие руки или нет. Она не безопаснее ключа от квартиры. По-моему, я все четко описал в статье.
Последняя просьба прозвучала немного странно, ибо нажатие кнопки вводит пароль в совершенно открытом виде…
Что касается вашей ссылки — это скорее не хранилище паролей, а аутентификатор. И работает только с теми, кто поддерживает такую же технологию. И для настройки устройства нужно ставить специальный софт. Решение неплохое, но явно не универсальное.
+
avatar
0
  • vedroid
  • 26 августа 2026, 00:32
Я не понял в чем здесь смысл шифрования если сам ключ шифрования храниться на самом устройстве: prefs.putBytes(«key», aesKey, 32); и даже совсем чуть подкованный технарь-любитель достанет ключ из «prefs». По хорошему, ваша биометрия должна преобразовываться в ключ шифрования с помощью специальной key derivation функции (по аналогии с обычной key derivation функцией для преобразования мастер-пароля в ключ шифрования), а сам ключ шифрования нигде не должен сохранятся. Эти борды ведь вроде поддерживают eFuse, вот там и нужно было инициализировать ключ шифрования (но даже так, eFuse форенсики прочитают если нужно будет, но любители нет).
+
avatar
0
У программистов есть правило — никогда не писать криптографию самому, а использовать готовые проверенные библиотеки.
Надо было не изобретать велосипед, а сказать клиенту чтобы купил какой-нибудь yubikey.
+
avatar
0
  • vedroid
  • 26 августа 2026, 01:01
Здесь не изобретаются алгоритмы шифрования, но как раз используются готовые. Другое дело что оно — готовое библиотечное шифрование, здесь используется таким образом, что становится бессмысленным.
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.