This document describes the security architecture and hardening measures implemented in Bittle X Explorer, including browser-side encrypted API key management, hardware rate limiting, and HTTP security headers.
Previous Architecture: The app relied on a server-side environment variable (GEMINI_API_KEY) to provide the Gemini API key. The /api/translate endpoint had no authentication or rate limiting, making it a public proxy that anyone could abuse to consume your Gemini API quota without restriction.
Risk Profile:
- High: Unprotected API endpoint exposed to the internet
- The
GEMINI_API_KEYnever leaves the server, but the server acts as an open relay - Attacker could bulk-request translations, costing you money and exhausting quota
New Architecture: Users provide their own Gemini API key via a secure Settings panel. The key is encrypted locally and never sent to the app's server.
-
User enters API key in Settings panel (SettingsPanel.tsx)
- Input field with password masking and visibility toggle
- Real-time validation via test Gemini API call before saving
-
Key is encrypted with Web Crypto AES-GCM
- Algorithm: AES-GCM with 256-bit key
- Fresh 96-bit IV generated for each encryption
- Encryption key is non-extractable and stored in IndexedDB
-
Encrypted blob stored in localStorage
- Format:
{ iv: "base64", ciphertext: "base64" } - Stored at
localStorage['bittle.gemini.encryptedKey'] - Cannot be read as plaintext by XSS, only decrypted within same origin
- Format:
-
On command execution
- Key is decrypted in-memory from IndexedDB + localStorage
- Decrypted key is used directly with
@google/genaiSDK in browser - No server roundtrip; direct call to
generativelanguage.googleapis.com
| Threat | Mitigation | Residual Risk |
|---|---|---|
| Server compromise | Key never stored on server; only encrypted blob in localStorage | If server is fully compromised, attacker could inject code to intercept decrypted keys |
| XSS attack | Non-extractable AES key in IndexedDB; ciphertext-only in localStorage | XSS can still call loadApiKey() and get plaintext key in memory for in-session use only |
| Network sniffing | All communication over HTTPS; browser doesn't send key to server | HTTPS is required; CSP and security headers prevent cross-origin exfiltration |
| Data exfiltration | connect-src CSP restricts outbound connections; only generativelanguage.googleapis.com allowed |
User must stay on bittle-x.vercel.app or trusted self-hosted domain |
| Key logging | Key only in memory during command translation; never logged or stored as plaintext | User's local machine is out of scope |
- New:
services/keyStorageService.ts— AES-GCM encryption/decryption, IndexedDB key storage - New:
components/SettingsPanel.tsx— User interface for key entry and robot model selection - Modified:
services/geminiService.ts— Rewritten to use browser SDK directly; throwsApiKeyNotConfiguredErrorif key missing - Modified:
components/AIController.tsx— HandlesApiKeyNotConfiguredError, shows key status badge, triggers Settings panel - Modified:
App.tsx— Wires Settings panel, adds Settings button to header
The app now supports both Bittle X (dog) and Nybble Q (cat) robots. Both run the same OpenCat firmware and share the same command vocabulary; the difference is only in the AI system prompt identity.
- User selects model in Settings panel (default: Bittle X)
- Selection saved to
localStorage['bittle.robotModel'] - AI system instruction adapts: "robot dog named Bittle" vs "robot cat named Nybble Q"
- Modified:
services/geminiService.ts— AddedgetSystemInstruction(robotModel)function - Modified:
components/SettingsPanel.tsx— Robot model selector buttons - Modified:
components/AIController.tsx— Reads robot model from localStorage, passes totranslateCommand()
The Petoi Bittle X uses a resource-constrained microcontroller (NyBoard/BiBoard) that can be overwhelmed by rapid-fire commands, causing the robot to reboot or become unresponsive.
Example: Sending 10 commands in 100ms could exceed the robot's input buffer, leading to silent resets.
Minimum throttle of 150ms between commands enforced at the sendCommand() function level in App.tsx.
const COMMAND_THROTTLE_MS = 150;
lastCommandTime = useRef<number>(0);
const sendCommand = async (cmd: string) => {
// Check if enough time has passed since last command
const now = Date.now();
const timeSinceLastCommand = now - lastCommandTime.current;
if (timeSinceLastCommand < COMMAND_THROTTLE_MS) {
// Wait for the remaining throttle duration
await new Promise(resolve =>
setTimeout(resolve, COMMAND_THROTTLE_MS - timeSinceLastCommand)
);
}
lastCommandTime.current = Date.now();
// ... then send command to robot
};- ✅ Manual control pad clicks
- ✅ Gamepad input
- ✅ Direct terminal commands
- ✅ AI sequence execution (additive to existing 800ms inter-command waits)
- ✅ All connection types (Bluetooth, USB Serial, WiFi)
- Current: 150ms minimum between commands
- Rationale: Bittle X firmware processes commands at ~100ms granularity; 150ms provides safe margin
- To adjust: Edit
COMMAND_THROTTLE_MSinApp.tsxline 57
The handleEmergencyStop() function sends BALANCE command directly without throttle, ensuring immediate robot stop in emergencies.
- Modified:
App.tsx— AddedCOMMAND_THROTTLE_MSconstant, updatedsendCommand()with throttle check
{
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains; preload"
},
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
},
{
"key": "Permissions-Policy",
"value": "geolocation=(), camera=(), microphone=(self)"
},
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://esm.sh; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self' https://esm.sh https://generativelanguage.googleapis.com; img-src 'self' data:; worker-src 'none'; frame-ancestors 'none'"
}
]
}
]
}| Header | Purpose | Value |
|---|---|---|
| HSTS | Enforce HTTPS for 2 years | max-age=63072000; includeSubDomains; preload |
| X-Content-Type-Options | Prevent MIME-type sniffing | nosniff |
| X-Frame-Options | Prevent clickjacking | DENY |
| Referrer-Policy | Control referrer leakage | strict-origin-when-cross-origin |
| Permissions-Policy | Disable dangerous APIs | geolocation=(), camera=(), microphone=(self) |
| CSP | Prevent XSS and data exfiltration | See below |
default-src 'self'— Only load resources from same origin by defaultscript-src— Allows:'self'— App's own scripts'unsafe-inline'— Required for inline<style>and config scripts (vite/tailwind)https://cdn.tailwindcss.com— Tailwind CSS CDNhttps://esm.sh— ES Module CDN (React, deps)
connect-src— Allows:'self'— Same origin (API routes, if any)https://esm.sh— Module loadinghttps://generativelanguage.googleapis.com— Gemini API endpoint (critical for browser-side keys)
worker-src 'none'— Web Workers disabledframe-ancestors 'none'— Cannot be framed
Note: 'unsafe-inline' for scripts is necessary for inline Tailwind and vite module configs. A stricter nonce-based approach would require restructuring index.html.
vite.config.ts: Changed host: '0.0.0.0' → host: 'localhost'
- Prevents accidental exposure of dev server to LAN
- Only localhost connections allowed during development
Unit tests are provided for all new/modified security-critical functions:
-
Install Vitest and testing dependencies:
npm install -D vitest @vitest/ui @testing-library/react @testing-library/jest-dom
-
Add to
vite.config.ts:export default defineConfig({ test: { globals: true, environment: 'happy-dom', }, });
-
Run tests:
npm run test
| File | Test File | Coverage |
|---|---|---|
services/keyStorageService.ts |
tests/services/keyStorageService.test.ts |
Encryption, storage, retrieval, clearing |
services/geminiService.ts |
tests/services/geminiService.test.ts |
Error handling, API calls, robot model switching |
components/SettingsPanel.tsx |
tests/components/SettingsPanel.test.tsx |
UI interactions, key validation, model selection |
The server-side /api/translate endpoint is now deprecated. It remains in place as a fallback for self-hosted deployments that prefer server-side key management, but new usage should use the browser-side approach.
To re-enable the server endpoint, revert services/geminiService.ts to proxy mode and restore the GEMINI_API_KEY environment variable.
- Nonce-based CSP — Use nonce for inline scripts to eliminate
'unsafe-inline' - Subresource Integrity (SRI) — Add SRI hashes to CDN loads for added verification
- Certificate Pinning — Pin Vercel/Gemini API certificates (if self-hosting)
- Rate Limiting at Edge — Use Vercel Edge Middleware to rate-limit requests
- TOTP-backed Key Rotation — Implement periodic key refresh prompts
- Audit Logging — Log key creation/deletion events (e.g., to browser console or external service)