Sayantan Chatterjee
Published © GPL3+

PocketVault: A Keychain-Sized BLE Password Manager

An ultra-portable, completely offline hardware password vault built on the M5StickC PLUS2 secures your credentials using AES-256 encryption.

AdvancedFull instructions provided69
PocketVault: A Keychain-Sized BLE Password Manager

Things used in this project

Hardware components

M5StickC PLUS2
M5Stack M5StickC PLUS2
×1

Story

Read more

Code

PocketVault

C/C++
Paste it in Arduino IDE > Connect M5StickC PLUS2 > Upload the code
#include <M5StickCPlus2.h>
#include <BleKeyboard.h>
#include "mbedtls/md.h"
#include <vector>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <WiFiManager.h>
#include <WiFiClientSecure.h>
#include <HTTPUpdate.h>

BleKeyboard bleKeyboard("M5 Vault", "Espressif", 100);
Preferences preferences;
WebServer server(80);
DNSServer dnsServer;

const byte DNS_PORT = 53;

// ==========================================
// 🛑 CONFIGURATION 🛑
// ==========================================
String CONFIG_PIN = "0000"; // Enter this at the lock screen to launch the Wi-Fi portal
const unsigned long AUTO_LOCK_MS = 60000; // 60 seconds of inactivity
// ==========================================

std::vector<String> services;
int currentIndex = 0;

// Device States
enum DeviceMode { LOCKED, VAULT_MODE, CONFIG_MODE };
DeviceMode currentMode = LOCKED;

int pinDigits[4] = {0, 0, 0, 0};
int currentDigitIndex = 0;
String derivedMasterKey = "";

unsigned long lastActivityTime = 0;
bool lastBleState = false;

// ==========================================
// AUDIO FEEDBACK HELPERS
// ==========================================
void playClick() {
  StickCP2.Speaker.tone(4000, 30);
}

void playError() {
  StickCP2.Speaker.tone(1000, 300);
}

void playSuccess() {
  StickCP2.Speaker.tone(5000, 100);
  delay(100);
  StickCP2.Speaker.tone(7000, 150);
}

// ==========================================
// NVRAM & BATTERY HELPERS
// ==========================================
void loadServices() {
  preferences.begin("m5vault", false);
  String savedApps = preferences.getString("apps", "Gmail,SAP_Portal,HDFC_Bank,BGMI_Account");
  preferences.end();

  services.clear();
  int start = 0;
  int end = savedApps.indexOf(',');
  while (end != -1) {
    services.push_back(savedApps.substring(start, end));
    start = end + 1;
    end = savedApps.indexOf(',', start);
  }
  services.push_back(savedApps.substring(start)); 
}

void drawBattery() {
  int batLevel = StickCP2.Power.getBatteryLevel();
  int x = 185; int y = 6; int w = 42; int h = 14;
  
  StickCP2.Display.drawRect(x, y, w, h, TFT_DARKCYAN);
  StickCP2.Display.fillRect(x + w, y + 4, 3, 6, TFT_DARKCYAN);
  
  uint16_t fillColor = TFT_DARKGREEN;
  if (batLevel <= 20) fillColor = TFT_MAROON; 
  else if (batLevel <= 50) fillColor = TFT_DARKGREY; 
  
  int fillWidth = (w - 4) * batLevel / 100;
  if (fillWidth > 0) {
    StickCP2.Display.fillRect(x + 2, y + 2, fillWidth, h - 4, fillColor);
  }
  
  StickCP2.Display.setTextSize(1);
  StickCP2.Display.setTextColor(TFT_WHITE);
  if (batLevel == 100) StickCP2.Display.setCursor(x + 4, y + 4);
  else if (batLevel < 10) StickCP2.Display.setCursor(x + 13, y + 4);
  else StickCP2.Display.setCursor(x + 7, y + 4);
  StickCP2.Display.printf("%d%%", batLevel);
}

// ==========================================
// MAIN SETUP & LOOP
// ==========================================
void setup() {
  auto cfg = M5.config();
  StickCP2.begin(cfg);
  StickCP2.Display.setBrightness(128); 
  StickCP2.Display.setRotation(1); 
  StickCP2.Speaker.setVolume(100);
 
  loadServices();
  lastActivityTime = millis();
  
}

void loop() {
  StickCP2.update();
  
  if (StickCP2.BtnA.wasPressed() || StickCP2.BtnB.wasPressed()) {
    lastActivityTime = millis();
  }

  if (currentMode == LOCKED) {
    handleLockScreen();
  } else if (currentMode == VAULT_MODE) {
    handleVaultMode();
  } else if (currentMode == CONFIG_MODE) {
    dnsServer.processNextRequest();
    server.handleClient(); 
  }
}

// ==========================================
// MODE HANDLERS
// ==========================================
void handleLockScreen() {
  if (StickCP2.BtnB.wasPressed()) { 
    playClick();
    pinDigits[currentDigitIndex] = (pinDigits[currentDigitIndex] + 1) % 10;
    drawLockScreen();
  }
  
  if (StickCP2.BtnA.wasPressed()) { 
    currentDigitIndex++;
    
    if (currentDigitIndex >= 4) {
      derivedMasterKey = String(pinDigits[0]) + String(pinDigits[1]) + String(pinDigits[2]) + String(pinDigits[3]);
      
      if (derivedMasterKey == CONFIG_PIN) {
        playSuccess();
        startConfigPortal();
      } else {
        playSuccess();
        currentMode = VAULT_MODE;
        bleKeyboard.begin();
        drawUI();
      }
    } else {
      playClick();
      drawLockScreen();
    }
  }
}

void handleVaultMode() {
  if (millis() - lastActivityTime > AUTO_LOCK_MS) {
    currentMode = LOCKED;
    currentDigitIndex = 0;
    pinDigits[0] = 0; pinDigits[1] = 0; pinDigits[2] = 0; pinDigits[3] = 0;
    derivedMasterKey = "";
    if (bleKeyboard.isConnected()) bleKeyboard.end(); 
    drawLockScreen();
    return;
  }

  bool currentBleState = bleKeyboard.isConnected();
  if (currentBleState != lastBleState) {
    lastBleState = currentBleState;
    drawUI(); 
  }

  if (StickCP2.BtnB.wasPressed()) {
    playClick(); 
    currentIndex = (currentIndex + 1) % services.size();
    drawUI();
  }

  if (StickCP2.BtnA.wasPressed()) {
    if (bleKeyboard.isConnected()) {
        playSuccess(); 
        StickCP2.Display.fillScreen(TFT_BLACK);
        StickCP2.Display.drawRect(10, 10, 220, 115, TFT_GREEN);
        StickCP2.Display.setTextColor(TFT_GREEN);
        StickCP2.Display.setTextSize(2);
        StickCP2.Display.setCursor(30, 45);
        StickCP2.Display.print("EXECUTING...");
        StickCP2.Display.setTextSize(1);
        StickCP2.Display.setCursor(30, 75);
        StickCP2.Display.print("INJECTING PAYLOAD");
        
       String password = generateStatelessPassword(derivedMasterKey, services[currentIndex], 16);
        
        // SLOW-TYPE FIX: Type one character at a time to prevent BLE buffer overflow
        for (int i = 0; i < password.length(); i++) {
          bleKeyboard.print(password[i]);
          delay(25); // 25ms gap gives your PC time to register the key release
        }
        
        // SAFETY CATCH: Force the OS to lift all virtual keys just in case
        bleKeyboard.releaseAll(); 
        
        password = "";
        
        delay(600); 
        lastActivityTime = millis(); 
        drawUI();   
    } else {
        playError(); 
        StickCP2.Display.fillScreen(TFT_BLACK);
        StickCP2.Display.drawRect(10, 10, 220, 115, TFT_RED);
        StickCP2.Display.setTextColor(TFT_RED);
        StickCP2.Display.setTextSize(2);
        StickCP2.Display.setCursor(30, 55);
        StickCP2.Display.print("[NO SIGNAL]");
        delay(1000);
        drawUI();
    }
  }
}

// ==========================================
// CAPTIVE PORTAL & WEB SERVER
// ==========================================
void startConfigPortal() {
  currentMode = CONFIG_MODE;
  
  WiFi.softAP("M5_Vault", "password123"); 
  dnsServer.start(DNS_PORT, "*", WiFi.softAPIP());
  
  server.onNotFound([]() {
    server.sendHeader("Location", String("http://") + WiFi.softAPIP().toString(), true);
    server.send(302, "text/plain", "");
  });

  server.on("/", HTTP_GET, []() {
    preferences.begin("m5vault", true); 
    String currentList = preferences.getString("apps", "Gmail,SAP_Portal,HDFC_Bank,BGMI_Account");
    preferences.end();
    
    String html = R"rawliteral(
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>M5 Vault Terminal</title>
        <style>
            :root { --bg: #050505; --panel: #0a0a0a; --text: #00ff00; --accent: #00ffcc; --border: #222; }
            body { background: var(--bg); color: var(--text); font-family: "Courier New", Courier, monospace; display: flex; justify-content: center; padding: 20px; margin: 0; }
            .vault-card { background: var(--panel); border: 1px solid var(--border); box-shadow: 0 0 20px rgba(0, 255, 204, 0.05); padding: 30px; width: 100%; max-width: 450px; position: relative; overflow: hidden; }
            .vault-card::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px; background: rgba(0, 255, 204, 0.3); animation: scan 4s linear infinite; pointer-events: none; }
            @keyframes scan { 0% { top: -10%; } 100% { top: 110%; } }
            h2 { margin: 0 0 5px; color: var(--accent); text-transform: uppercase; font-size: 22px; letter-spacing: 2px; }
            .status { font-size: 11px; color: #555; margin-bottom: 25px; text-transform: uppercase; letter-spacing: 1px; }
            textarea { width: 100%; background: #000; color: var(--text); border: 1px solid var(--border); padding: 15px; box-sizing: border-box; font-family: inherit; font-size: 15px; min-height: 180px; outline: none; transition: 0.3s; line-height: 1.5; }
            textarea:focus { border-color: var(--accent); box-shadow: inset 0 0 15px rgba(0, 255, 204, 0.1); }
            button { background: transparent; color: var(--accent); border: 1px solid var(--accent); padding: 15px; width: 100%; margin-top: 25px; font-family: inherit; font-size: 16px; font-weight: bold; text-transform: uppercase; letter-spacing: 2px; cursor: pointer; transition: 0.3s; }
            button:hover { background: var(--accent); color: #000; box-shadow: 0 0 20px var(--accent); }
        </style>
    </head>
    <body>
        <div class="vault-card">
            <h2>SYS.VAULT_CONFIG</h2>
            <div class="status">CONNECTION: SECURE | ENCRYPTION: SHA-256</div>
            <form action="/save" method="POST">
                <textarea name="apps" spellcheck="false" placeholder="Enter_Services_Here...">)rawliteral" + currentList + R"rawliteral(</textarea>
                <button type="submit">Initialize Flash Write</button>
            </form>
        </div>
    </body>
    </html>
    )rawliteral";
    
    server.send(200, "text/html", html);
  });

  server.on("/save", HTTP_POST, []() {
    String newApps = server.arg("apps");
    newApps.replace(" ", ""); 
    newApps.replace("\n", ""); 
    newApps.replace("\r", "");
    
    preferences.begin("m5vault", false);
    preferences.putString("apps", newApps);
    preferences.end();
    
    String successHtml = R"rawliteral(
    <!DOCTYPE html><html><head><meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>body{background:#050505;color:#00ffcc;font-family:"Courier New",monospace;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;text-align:center;} .box{border:1px solid #222;padding:40px;box-shadow:0 0 20px rgba(0,255,204,0.1);} h1{margin:0 0 10px 0;font-size:24px;text-transform:uppercase;letter-spacing:2px;} p{color:#00ff00;font-size:14px;}</style></head>
    <body><div class="box"><h1>[ NVRAM WRITE OK ]</h1><p>SYSTEM REBOOTING...<br>CONNECTION TERMINATED.</p></div></body></html>
    )rawliteral";

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

  server.begin();

  StickCP2.Display.fillScreen(TFT_BLACK);
  StickCP2.Display.fillRect(2, 2, 236, 25, TFT_CYAN);
  StickCP2.Display.setTextColor(TFT_BLACK);
  StickCP2.Display.setTextSize(1);
  StickCP2.Display.setCursor(10, 8);
  StickCP2.Display.print("SYS.HOSTING_PORTAL");
  
  StickCP2.Display.drawRect(2, 2, 236, 131, TFT_CYAN);

  StickCP2.Display.setTextSize(1);
  StickCP2.Display.setTextColor(TFT_GREEN);
  StickCP2.Display.setCursor(10, 45);
  StickCP2.Display.print("SSID: M5_Vault");
  StickCP2.Display.setCursor(10, 65);
  StickCP2.Display.print("PASS: password123");
  
  StickCP2.Display.setTextColor(TFT_YELLOW);
  StickCP2.Display.setCursor(10, 105);
  StickCP2.Display.print("AWAITING DNS CLIENT...");
}

// ==========================================
// HARDWARE UI RENDERING
// ==========================================
void drawLockScreen() {
  StickCP2.Display.fillScreen(TFT_BLACK);
  StickCP2.Display.drawRect(2, 2, 236, 131, TFT_DARKCYAN);
  StickCP2.Display.drawLine(2, 25, 238, 25, TFT_DARKCYAN);

  StickCP2.Display.setTextSize(1);
  StickCP2.Display.setTextColor(TFT_GREEN);
  StickCP2.Display.setCursor(10, 8);
  StickCP2.Display.print("SYS.AUTH_MODULE");

  StickCP2.Display.setTextSize(3);
  for (int i = 0; i < 4; i++) {
    int xPos = 40 + (i * 40);
    StickCP2.Display.setCursor(xPos, 55);
    
    if (i == currentDigitIndex) {
      // Fixed: Green text, black background (No green box!)
      StickCP2.Display.setTextColor(TFT_GREEN, TFT_BLACK); 
      StickCP2.Display.print(pinDigits[i]);
    } else if (i < currentDigitIndex) {
      StickCP2.Display.setTextColor(TFT_DARKGREY, TFT_BLACK);  
      StickCP2.Display.print("*");
    } else {
      StickCP2.Display.setTextColor(TFT_DARKCYAN, TFT_BLACK); 
      StickCP2.Display.print("_");
    }
  }
  
  StickCP2.Display.drawLine(2, 110, 238, 110, TFT_DARKCYAN);
  StickCP2.Display.setTextSize(1);
  StickCP2.Display.setTextColor(TFT_DARKCYAN, TFT_BLACK);
  StickCP2.Display.setCursor(10, 116);
  StickCP2.Display.print("BTN.S: SHIFT");
  StickCP2.Display.setCursor(140, 116);
  StickCP2.Display.print("BTN.M: LOCK");

  drawBattery();
}

void drawUI() {
  StickCP2.Display.fillScreen(TFT_BLACK);
  StickCP2.Display.drawRect(2, 2, 236, 131, TFT_DARKCYAN);
  StickCP2.Display.drawLine(2, 25, 238, 25, TFT_DARKCYAN);

  StickCP2.Display.setTextSize(1);
  StickCP2.Display.setTextColor(TFT_DARKGREY);
  StickCP2.Display.setCursor(10, 8);
  StickCP2.Display.print("TARGET:");

  StickCP2.Display.setCursor(120, 8);
  if (bleKeyboard.isConnected()) {
    StickCP2.Display.setTextColor(TFT_GREEN);
    StickCP2.Display.print("[UPLINK]");
  } else {
    StickCP2.Display.setTextColor(TFT_RED);
    StickCP2.Display.print("[NO_SIG]");
  }

  StickCP2.Display.fillRect(10, 45, 216, 40, TFT_DARKGREEN);
  StickCP2.Display.drawRect(10, 45, 216, 40, TFT_GREEN);
  
  StickCP2.Display.setTextSize(2);
  StickCP2.Display.setTextColor(TFT_BLACK); 
  StickCP2.Display.setCursor(20, 56);
  StickCP2.Display.print("> " + services[currentIndex]);
  
  StickCP2.Display.drawLine(2, 110, 238, 110, TFT_DARKCYAN);
  StickCP2.Display.setTextSize(1);
  StickCP2.Display.setTextColor(TFT_DARKCYAN, TFT_BLACK);
  StickCP2.Display.setCursor(10, 116);
  StickCP2.Display.print("BTN.S: CYCLE");
  StickCP2.Display.setCursor(130, 116);
  StickCP2.Display.print("BTN.M: EXECUTE");

  drawBattery();
}

// ==========================================
// CRYPTOGRAPHIC ENGINE
// ==========================================
String generateStatelessPassword(String masterKey, String service, int length) {
  String combined = masterKey + "|" + service;
  byte shaResult[32];
  mbedtls_md_context_t ctx;
  mbedtls_md_init(&ctx);
  mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 0);
  mbedtls_md_starts(&ctx);
  mbedtls_md_update(&ctx, (const unsigned char*)combined.c_str(), combined.length());
  mbedtls_md_finish(&ctx, shaResult);
  mbedtls_md_free(&ctx);
  
  String charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
  String finalPassword = "";
  for (int i = 0; i < length; i++) {
    finalPassword += charset.charAt(shaResult[i % 32] % charset.length());
  }
  return finalPassword;
}

Credits

Sayantan Chatterjee
2 projects • 0 followers

Comments