Last updated: April 2026 Audience: Developers
| Board | Display | MCU | PSRAM | USB HID | BLE HID |
|---|---|---|---|---|---|
| T-Display ESP32 | 1.14" SPI (135×240) | ESP32 dual-core 240MHz | No | No | Yes |
| T-Display-S3 | 1.9" Parallel (170×320) | ESP32-S3 dual-core 240MHz | 8MB | Yes | Yes |
Multi-board architecture: All board-specific code is isolated in include/boards/board_*.h. Business logic uses hardware abstraction macros. See Multi-Board Support for porting guide.
Application Layer
├── TOTP/HOTP Generator
├── Password Manager
├── BLE HID Keyboard
└── USB HID Keyboard (S3 only)
Service Layer
├── Web Server
├── WiFi Manager
└── Display Manager
Security Layer
├── Crypto Manager
├── Secure Layer Manager
└── URL Obfuscation Manager
Platform
├── LittleFS
├── ESP32/S3 Hardware
└── mbedTLS
Manager responsibilities:
| Manager | Responsibility |
|---|---|
CryptoManager |
Device key, PBKDF2, AES, RNG, session storage, dual-slot space management, HMAC-derived file paths |
SecureLayerManager |
ECDH key exchange, AES-256-GCM transport encryption |
KeyManager |
TOTP/HOTP key storage and code generation |
PasswordManager |
Password storage and retrieval |
PinManager |
PIN entry UI, BLE PIN configuration |
USBHIDManager |
USB HID keyboard (S3 only), password typing via USB |
WifiManager |
WiFi client, AP mode, mDNS |
WebServerManager |
HTTP server, API endpoints, authentication |
ConfigManager |
Non-sensitive configuration persistence |
DisplayManager |
TFT display, UI rendering, themes |
BatteryManager |
ADC battery monitoring |
RTCManager |
DS3231 hardware RTC: config load/save, I2C init, time read/write, boot compensation |
- Serial init, LogManager init
- GPIO init — board-specific pins loaded from
include/boards/board_*.h- ESP32: BTN1=GPIO35, BTN2=GPIO0
- S3: BTN1=GPIO14, BTN2=GPIO0
- Factory reset detection — both buttons held at boot
- BatteryManager init — board-specific ADC channels
- Display init — board-specific TFT configuration (SPI vs Parallel)
- USB HID init (S3 only) — if enabled in config
Board detection: Compile-time via ARDUINO_LILYGO_T_DISPLAY_S3 define set by PlatformIO environment.
Factory reset trigger: Both buttons held for 5 seconds during boot. Deletes all user data files, NVS partition (BLE bonding keys), URL obfuscation mappings, PIN attempt counter. Device reboots into first-boot flow.
- LittleFS.begin() — formats on failure
- Check for
/device.keyexistence
If LittleFS mount fails: device halts with error screen.
CryptoManager::begin()— initializes mbedTLS CTR_DRBG with hardware entropy
Device key path branches: /device.key missing → First boot: PIN creation + key generation /device.key exists (81 b) → Normal boot: PIN entry → slot A unlock → Space A alternate PIN → slot B unlock → Space B /device.key exists (≤33 b) → Legacy: key loaded without PIN (no space selection)
Hidden Space boot path (normal boot):
- File size = 161 bytes → dual-slot file present
- Try entered PIN against slot A (offset 0): PBKDF2 + AES-CBC + MAGIC check
- If slot A decrypts → Space A loads, Space B slot untouched
- If slot A fails → try same PIN against slot B (offset 81)
- If slot B decrypts → Space B loads, Space A slot untouched
- If both fail → increment
/.pin_attemptscounter
First boot (PIN creation):
- User selects PIN length (4–10 digits)
- User enters and confirms PIN
- Device generates 32-byte random device key
- Key encrypted with PBKDF2(PIN) → AES-256-CBC → saved as 81-byte file
- Cannot proceed without completing PIN setup
Normal boot (PIN entry):
- Load persistent attempt counter from
/.pin_attempts - If counter ≥ 5: show "DEVICE LOCKED", enter deep sleep (factory reset required)
- PIN entry prompt shown on device screen
- Each failed attempt: increment and save counter to flash
- After 5 total failed attempts (across reboots): permanent lockout
- Successful unlock: counter file deleted
PIN verification mechanism: PIN is never stored. Verification works by attempting PBKDF2(PIN, salt) → AES-256-CBC decrypt → SHA256 checksum comparison. If checksum matches, PIN is correct and device key is loaded into RAM.
SecureLayerManager::begin()— ECDH context preparedWebAdminManager::begin()— load registration and session state
- Theme, BLE name, startup mode loaded from config
KeyManager::begin()— load encrypted TOTP/HOTP keysPasswordManager::begin()— load encrypted passwordsPinManager::begin()— load PIN UI preferences- Splash screen (if enabled)
Between PIN unlock and mode selection, if DS3231 is enabled in config:
Wire.begin(sda_pin, scl_pin)
rtc.begin() → chip found?
Yes → rtc.now().unixtime() > 1609459200?
Yes → settimeofday() → system clock set from RTC
No → battery dead or never calibrated → timeSynced = false
No → LOG_ERROR, timeSynced = false
Config stored in /rtc_config.json: {"enabled": bool, "sda_pin": int, "scl_pin": int}
Default pins: SDA=21, SCL=22. Custom pins applied via reinit() on-the-fly without reboot.
Per-mode behaviour after RTC init:
| Mode | RTC enabled + valid | RTC disabled or invalid |
|---|---|---|
| WiFi | Sets initial time, then NTP overwrites and saves back to RTC | NTP is mandatory source |
| AP | TOTP works immediately; user can re-sync via web cabinet | System clock zeroed → NOT SYNCED |
| Offline | TOTP works; re-sync on next AP/WiFi boot | NOT SYNCED |
API time format: GET /api/rtc always returns rtc_time as a UTC ISO-8601
string with a trailing Z (e.g. 2026-08-07T14:32:15Z), derived via
gmtime_r() — never localtime_r(). This is intentional: any localtime
conversion here previously caused false drift-detection on the client
whenever the device timezone and browser timezone differed. New code that
touches RTC time serialization must preserve this — convert to local time
only at the point of on-screen/UI display, never before an epoch comparison.
Pseudo-sleep re-sync: On every wake from pseudo-sleep, if DS3231 is available, syncFromRTC() is called to correct ESP32 internal RTC drift accumulated during sleep. Note: pseudo-sleep reduces CPU to 40 MHz and suspends the TFT controller — it does not use esp_light_sleep_start() due to hardware incompatibility with battery power (voltage drop on CPU wake causes POWER_ON reset).
NTP → RTC write-back (WiFi mode): After successful NTP sync, time is written to DS3231 on a second boundary (busy-wait for tv_sec rollover) to minimize sub-second accumulation error.
configManager.getBootMode()→ load saved default ("wifi"/"ap"/"offline")- User prompt: two non-default modes shown as buttons (2-second timeout → saved default)
- Button press resets timeout; BUTTON_2 confirms immediately
Default is configurable via web cabinet (Settings → Boot Mode). Factory default: "wifi". Stored as boot_mode field in /config.json.
WiFi mode:
- Load encrypted WiFi credentials
- Connect, obtain IP via DHCP, start mDNS
- NTP sync (time.google.com → pool.ntp.org → time.cloudflare.com)
- Disconnect WiFi after sync (power saving)
- Prompt to start web server (5s timeout)
AP mode:
- Create access point
ESP32-TOTP-Setupat 192.168.4.1 - Start DNS, mDNS
- Auto-start web server
Offline mode: Skip all network init.
- Watchdog timer init (10s timeout)
- Load display timeout config
- Enter main event loop
Initialization dependency order:
LittleFS → CryptoManager → Device Key → All encrypted data
→ SecureLayerManager
→ WebAdminManager
→ KeyManager, PasswordManager, PinManager
→ WebServer
PIN (never stored)
└─► PBKDF2-HMAC-SHA256 (PBKDF2_ITERATIONS_PIN iterations)
└─► PIN-derived key (32 bytes, never stored)
└─► AES-256-CBC decrypt
└─► Device key (32 bytes, RAM only)
└─► AES-256-CBC encrypt/decrypt
└─► All data files
| Size | Version | Format |
|---|---|---|
| 81 bytes | v3 (current) | [0x03][salt:16][IV:16][encrypted:48] |
| 65 bytes | v2 (legacy) | [0x02][salt:16][encrypted:48] — AES-ECB |
| 49 bytes | v1 (legacy) | [0x01][salt:16][encrypted:32] — no checksum |
| ≤33 bytes | v0/ancient | Unencrypted |
Encrypted payload (48 bytes) contains: [SHA256(device_key)[0:4]][device_key:32][padding:12]
PIN is never transmitted over the network. Disabling PIN via web cabinet works as follows:
- Web interface sends disable request
- Server sets in-memory flag
shouldPromptPinDisable = true - Server responds: "Enter PIN on device to confirm"
- Main loop detects flag, prompts PIN entry on physical device
- User enters correct PIN on device
- Device decrypts device key, saves it unencrypted (
[0x00][key:32]) - Device reboots
Enabling PIN requires factory reset because the existing device key must be replaced with one generated under proper entropy + PIN encryption:
- Web interface requests PIN enable
- Server checks: device key currently unencrypted
- Requires factory reset confirmation
- All data wiped, device reboots into first-boot PIN setup
The device has no hard power-off. "Shutdown" means entering deep sleep via esp_deep_sleep_start().
Wake configuration (board-specific):
- ESP32:
esp_sleep_enable_ext0_wakeup(GPIO_NUM_0, 0)— single GPIO wake - S3:
esp_sleep_enable_ext1_wakeup((1ULL << GPIO_NUM_0), ESP_EXT1_WAKEUP_ANY_LOW)— ext1 wake source
Wake button is GPIO0 (▼ Bottom button) on both boards. RST button also wakes the device.
| Trigger | Location | Condition |
|---|---|---|
| Hold BTN2 5 seconds | src/main.cpp |
In TOTP or Password display mode |
| Hold both buttons 1 second | src/pin_manager.cpp |
During PIN entry (initial screen → deep sleep; confirm screen → back) |
| PIN lockout | src/main.cpp |
5 failed attempts reached |
| Auto Lock timeout | src/main.cpp |
Inside pseudo-sleep polling loop when auto_lock_timeout > 0 |
| Auto Lock (screen=Never) | src/main.cpp |
In main loop when screen_timeout == 0 and auto_lock_timeout > 0 |
Before every esp_deep_sleep_start() call, secureShutdown() is called:
void secureShutdown() {
cryptoManager.wipeDeviceKey(); // memset _deviceKey[32]
keyManager.wipeSecrets(); // zero + clear TOTP secrets
passwordManager.wipePasswords(); // zero + clear passwords + wildcard session
secureLayerManager.wipeAllSessions(); // zero session keys, free ECDH context
#ifdef ARDUINO_LILYGO_T_DISPLAY_S3
usbHIDManager.end(); // release USB HID (S3 only)
#endif
}This wipes all sensitive data from RAM before the device enters deep sleep. The wipePasswords() call internally invokes wipeWildcardSession(), which uses secureWipeString() to zero the cached wildcard password value (see security_model.md for wildcard RAM zeroing details). On S3, USB HID is also properly released.
A parallel wrapper, secureRestart(), performs the identical wipe sequence
before ESP.restart() (used for warm reboots, as opposed to secureShutdown()
which precedes esp_deep_sleep_start()):
void secureRestart() {
wipeWildcardSession();
CryptoManager::getInstance().wipeDeviceKey();
keyManager.wipeSecrets();
passwordManager.wipePasswords();
#ifdef SECURE_LAYER_ENABLED
secureLayerManager.wipeAllSessions();
#endif
ESP.restart();
}Call sites (25 total):
shouldRestartflag handler (web-triggered config changes: theme, boot mode, WiFi credentials, BLE/mDNS settings)- Factory Reset, PIN Disable confirmation, Hidden Space removal confirmation
- Hidden Space setup flow (all 4 exit paths — cancel, wrong PIN, cancel, success — additionally zero the local PIN strings
spaceAPin/newPin/confirmPinviasecureWipeString()before callingsecureRestart()) - 17 web cabinet API endpoints (
/api/reboot,/api/reboot_with_web,/api/change_ap_password,/api/hidden_space,/api/enter_import_export_mode) across direct, tunneled, and obfuscated routing paths
Intentionally left as bare ESP.restart() (no wipe, by design):
- Duress PIN wipe path — already performs a more thorough forensic wipe before restart
- Emergency low-memory restart (<20KB free heap) — calling additional functions at this threshold risks a crash before restart completes
- Legacy unencrypted key load failure — no key was ever loaded into RAM
- Import/Export AP startup failure — only a randomly generated, never-used AP password is in scope
| Mode | Web server |
|---|---|
| WiFi | User prompted at boot (5s timeout) |
| AP | Auto-starts always |
| Offline | Never starts |
Auto-start can be configured: web_server_auto_start in config.json.
Default: 10 minutes. Timer resets on any authenticated API call. On expiry: web server stops, WiFi disconnects. Restart requires device interaction.
Sessions are stored encrypted in /session.json.enc. They survive reboots. Duration is configurable (until reboot / 1h / 6h / 24h / 3 days). Cleared on logout or factory reset.
| File | Encryption | Contents |
|---|---|---|
/device.key |
PIN-derived key | Encrypted device key |
/keys.json.enc |
Device key | TOTP/HOTP secrets |
/passwords.json.enc |
Device key | Passwords |
/wifi_config.json.enc |
Device key | WiFi credentials |
/session.json.enc |
Device key | Web session data |
/ble_pin.json.enc |
Device key | BLE PIN |
/pin_config.json |
Device key | BLE PIN enabled flag, config version |
/config.json |
None (AP password field encrypted) | Theme, timeouts, startup mode, boot mode, USB HID enabled (S3) |
/ble_config.json |
None | BLE device name |
/mdns_config.json |
None | mDNS hostname |
/.sys_ui_prefs |
None | PIN length (UI preference only) |
/.pin_attempts |
None | Failed PIN attempt counter (integer) |
/boot_counter.txt |
None | URL obfuscation epoch counter |
/rtc_config.json |
None | DS3231 RTC enabled flag, SDA/SCL pins |
URL obfuscation mappings are fully pre-generated at startup via registerCriticalEndpoint() rather than on-demand. This avoids repeated flash writes during normal operation — all 38 mappings are written once per epoch (every 30 reboots).
Note: config.json is plaintext but contains no secrets — the AP password field within it is individually encrypted. PIN length in /.sys_ui_prefs is no longer visually observable on-screen (fixed-width mask displays all 10 slots regardless of configured length); however, auto-submit timing still reveals approximate length to a live observer — documented known limitation, accepted trade-off for two-button UX. PBKDF2 cost makes brute-force attacks impractical regardless.
Visual indicators displayed on device screen when viewing passwords:
| Badge | Meaning | Color |
|---|---|---|
| 🔒 (1 lock) | Weak password | Red |
| 🔒🔒 (2 locks) | Medium password | Yellow |
| 🔒🔒🔒 (3 locks) | Strong password | Yellow |
| DUP | Duplicate password (used elsewhere) | Amber |
| PIN | Password contains only digits | Red |
| NAME | Password contains account name | Orange |
| ENT | Auto-send enabled (auto-types on selection) | Green |
| L/T or L/E | Login field enabled (Tab or Enter navigation mode) | Cyan |
| RND | Wildcard password (randomly generated) | Magenta |
Badges are rendered by DisplayManager::drawPasswordPage() and help users identify security issues.
- LittleFS mount failure → error screen, halt
- PIN attempt limit reached → deep sleep, factory reset required
- Crypto init failure → halt
- WiFi connection failure → retry or AP mode prompt
- NTP sync failure on first server → try next server
- Web server start failure → continue without web
- Session load failure → require re-login
- Wrong PIN → retry (up to 5 total across reboots)
- WiFi credentials invalid → AP mode for reconfiguration
- Corrupted data file → factory reset
USB HID keyboard functionality is exclusive to T-Display-S3 due to native USB-OTG support.
Configuration:
- Enabled/disabled via web cabinet Settings tab
- Setting stored in
/config.jsonasusb_hid_enabled(boolean) - Requires device reboot to apply changes
Operation:
- When enabled, device appears as USB HID keyboard to connected computer
- Passwords typed via USB without BLE pairing
- No PIN protection required (physical USB connection implies authorized access)
- Works simultaneously with BLE HID if both enabled
Initialization:
USBHIDManager::begin()called during Phase 1 (Hardware init) if enabled- Uses
USB.begin()andKeyboard.begin()from ESP32-S3 USB library - Properly released in
secureShutdown()before deep sleep
To minimize RAM allocations and avoid execution overhead during frequent read operations:
- Const references: Getters in
KeyManagerandPasswordManager(getAllKeys(),getAllPasswords()) returnconst std::vector<T>&directly to internal storage instead of returning copies. Callers useconst auto&to bind without copying. - Deferred/write-time sorting: Sorting by
orderhappens only during mutating operations (loadKeys()/loadPasswords(),reorderKeys()/reorderPasswords(),replaceAllKeys()/replaceAllPasswords()on import) rather than on every read. Getters no longer sort or mutate state, making them safe to call frequently (e.g. every 250ms from the main loop TOTP display) without repeated allocation or CPU cost. - Rule for new managers: any future manager holding a
std::vector<T>member should follow this same pattern — expose reads viaconst std::vector<T>&, and perform any list-order maintenance at the point of mutation, not inside the getter.