A secure embedded telemetry platform that combines post-quantum cryptography, device identity based on SRAM PUF, a stateless gateway firewall, and blockchain-anchored integrity.
The system is designed for ESP32 and ESP8266 devices that report sensor data through an untrusted network to a backend that verifies device identity, decrypts payloads, and anchors message hashes on the Polygon Amoy testnet.
The repository is a monorepo with six packages: backend server, gateway proxy, on-chain hash storage, ESP firmware, admin console, and client registry. After the recent refactoring the architecture uses explicit service and repository layers in the server, a stateless gateway, HKDF-based key derivation, modular PlatformIO firmware for both ESP targets, and two Next/Vite frontends that share the same server API through env-driven rewrites.
@Ayush Anand @Krishay @Geetha Pai @Krish Kavin
ESP32 / ESP8266 Firmware (PlatformIO)
| PUF 512B (1024 hex) + MAC
| POST /encrypt { mac, plaintext } or { publicKey, plaintext }
| POST /data X-MAC-Address + X-SRAM-Data + { kyber, iv, payload }
v
Gateway :8824 (TypeScript, stateless)
| parseSramHex (1024 hex exact) + verifySram (Hamming < 350 bits)
| isDeviceBlocked → 403
| GET /client/metadata (fail-closed) → find by mac or puf
| known → POST /client/message | unknown → POST /admin/new (rate-limited)
v
Server :3000 (TypeScript, Express)
| DeviceService (ML-KEM-768 keypair per device)
| MessageService (Kyber decap → HKDF-SHA256 → AES-256-GCM → SHA-256 hash)
| BlockchainService (fire-and-forget anchor + retry interval)
| LogRepository (bounded 10k) + KeyVault (secret isolation)
v
Polygon Amoy HashStorage.sol
Record { sender, timestamp, hash, metadata } + HashStored event
Data flow is end to end encrypted. The gateway never decrypts. It only validates the PUF header and delegates device lookup to the server, which is the single source of truth for devices. The server decrypts, hashes, stores the message in memory, and queues the hash for on-chain storage without blocking the HTTP response.
EmbSecServer/
.env.example # single env template for server + gateway (root)
package.json # workspaces: server, blockchain, gateway, admin, client (React 19 pinned at root)
server/ # backend (Node 22, TypeScript, Express 5)
src/
index.ts # dotenv loading, config validation, retry interval
server.ts # createApp(), CORS, body limits, adminAuth
config.ts # loadConfig(), getConfig(), ADMIN_KEY, CORS_ORIGINS
shared.ts # EXPECTED_SRAM_HEX_LEN, FIRMWARE_HASH_REGEX
deviceStore.ts # in-memory Maps + bounded Logs (canonical storage)
crypto/kyber.ts # MlKem768 + HKDF-SHA256 → AES-256-GCM
repos/ # DeviceRepository, KeyVault, LogRepository, NewDeviceStore
services/ # DeviceService, MessageService, BlockchainService
middleware/adminAuth.ts # X-Admin-Key gate for /admin/*
routeHandler.ts # file-system router (Next.js style)
routers/
client/ # device, message, encrypt, metadata, publicKey
admin/ # logs, new, retry
types.ts # Device, Message, LogEntry
package.json
tsconfig.json
gateway/ # stateless firewall (TypeScript, Express 5)
src/
index.ts # dotenv, initGateway(), listen
app.ts # createGatewayApp(), /data, /encrypt, /admin/block
config.ts # forwardBase, blockThresholdBits, fetchTimeoutMs
pufVerifier.ts # parseSramHex, verifySram
blockedStore.ts # blockedDevices persisted to data.json only
pendingStore.ts # rate-limited pending approvals (30s cooldown, 5m TTL)
fetchUtil.ts # fetchWithTimeout
shared.ts # mirrors server shared constants
package.json
tsconfig.json
data.json # generated at runtime, gitignored (blockedDevices only)
index.legacy.js # pre-refactor 509-line JS kept for reference
blockchain/
contracts/HashStorage.sol # Solidity 0.8.20
scripts/deploy.js # hardhat deploy
hardhat.config.js # amoy network
admin/ # operator console (Vite + React 19, :8080)
src/
lib/api.ts # API_BASE=VITE_API_BASE, X-Admin-Key, gatewayFetch
hooks/use-api.ts # useDevices, usePendingDevice, useBlockDevice, useGatewayStatus
components/ # PendingDeviceCard, AddDeviceDialog (zod 1024/64 hex), masked PUF
pages/ # Dashboard, Devices, Logs (pagination, Polygonscan tx links)
.env.example # VITE_API_BASE, VITE_GATEWAY_BASE, VITE_ADMIN_KEY
client/ # self-service registry (Next 15 + React 19, :9002)
src/
lib/api.ts # NEXT_PUBLIC_API_BASE, ApiError, admin:true flag
hooks/ # use-pagination, use-gateway (read-only)
app/ # Home (poll /admin/new, enroll, paginated cards), devices/[id]
components/devices/ # DeviceCard, MacInput
.env.example # NEXT_PUBLIC_API_BASE, NEXT_PUBLIC_GATEWAY_BASE, NEXT_PUBLIC_ADMIN_KEY
next.config.ts # rewrites /client/* and /admin/* to API_BASE, outputFileTracingRoot
esp/ # firmware (PlatformIO, Arduino)
platformio.ini # env:esp8266, env:esp32, env:esp32c3
include/
config.h.example # copy to config.h (gitignored)
config.h # local WiFi + hosts (not committed)
puf.h
wifi_utils.h
gateway_client.h
src/
main.cpp # setup/loop, publicKey resolution, encrypt→data
puf.cpp # 512B PUF generation, hex encoding
wifi_utils.cpp # WiFi connect, MAC/IP helpers
gateway_client.cpp # /encrypt, /publicKey, /data HTTP clients
main.c # legacy single-file 243 lines (reference)
The server is the authoritative store. It does not use a database in the MVP. All routers delegate to services and repositories.
repos/DeviceRepository.tsandrepos/KeyVault.tsisolatedeviceStoreandsecretKeys.repos/LogRepository.tsboundsLogsto 10,000 entries.repos/NewDeviceStore.tsholds the lastPOST /admin/newpayload.services/DeviceService.tscreates devices withgenerateKyberKeyPair()(1184B publicKey) andcrypto.randomIntIDs.services/MessageService.tsdecrypts withdecapKyberCiphertext+HKDF-SHA256derivedAES-256-GCM, hashes{ data, timestamp, deviceId }with SHA-256, and callsservices/BlockchainService.ts.services/BlockchainService.tsprovidesanchorHash()(fire-and-forget, queues topendingHasheson failure) andretryPending()invoked every 30 seconds at startup and viaPOST /admin/retry. PreviouslyretryPendingHasheswas never scheduled.crypto/kyber.tsderives the AES key withhkdfSync('sha256', sharedSecret, salt=empty, info='EmbSec-AES256GCM', 32)instead of rawSHA-256.encryptKyberAesGcmToBase64anddecryptKyberAesGcmToStringvalidate IV 12B and payload length.server.tsappliesCORS(restricted whenCORS_ORIGINSis set),express.jsonlimit 50kb, andadminAuthon/admin/*whenADMIN_KEYis configured.config.tsvalidates ports and thresholds.routeHandler.tsmapssrc/routers/**/*.tsto Express routes.
See server/README.md for full API reference.
The gateway is a thin TypeScript proxy with no device registry. It keeps only the operator blocklist.
- Validates
X-MAC-AddressandX-SRAM-Data(exact 1024 hex chars) and blocks with Hamming distance onblockedDevices. - On
POST /datait fetchesGET /client/metadatafromFORWARD_BASE(fail-closed 502 on error or timeout 5s), looks upmacorpuf === sramHex, forwards toPOST /client/messageif found, otherwise notifiesPOST /admin/newwith{ puf: sramHex, mac, firmwareHash }(derived asSHA-256(sramHex)if not supplied) and returns 403pending_approval. Notifications are rate-limited to one per 30 seconds per PUF with 5 minute TTL. POST /encryptis a shim that proxies toFORWARD_BASE/client/encryptso ESP hard-coded encrypt URLs work regardless of host.POST /admin/blockandDELETE /admin/blockmanage the persistent blocklist ingateway/data.json(migrates legacy files that contained adevicesarray).
PlatformIO project building for both ESP8266 and ESP32 from one codebase.
platformio.inidefinesenv:esp8266(espressif8266, board nodemcuv2),env:esp32(espressif32, board esp32dev),env:esp32c3, withbblanchon/ArduinoJson@6.21.3.default_envs = esp32.include/config.h(gitignored) holdsWIFI_SSID,WIFI_PASSWORD,GATEWAY_HOST,GATEWAY_PORT,SERVER_HOST,SERVER_PORT,HARDCODED_PUBLIC_KEY, timeouts. Copyconfig.h.exampleto create it.puf.cppgenerates 512B once at boot (esp_random()on ESP32,analogRead(A0) ^ os_random()on ESP8266) and exposespuf_toHex()for the 1024-char header.wifi_utils.cpphandles STA connect withWIFI_CONNECT_TIMEOUT_MS15s.gateway_client.cppimplementsgateway_encrypt,gateway_encryptViaMac,gateway_fetchPublicKey, andgateway_sendDatawithHTTPClientdifferences abstracted for ESP8266 (WiFiClient+begin(client, url)) and ESP32 (begin(url)).src/main.cppresolves the public key in order hardcoded → cached →POST /client/publicKey {mac}viaSERVER_HOST, falls back togateway_encryptViaMac(server resolves by mac), then loops everyLOOP_INTERVAL_MS5s:wifi_isConnected→puf_toHex→plaintext {mac, sram}→ encrypt →POST /datawithX-MAC-AddressandX-SRAM-Data.
Operator UI on Vite :8080 with React 19, admin/src/lib/api.ts:1 (VITE_API_BASE default 3000, VITE_GATEWAY_BASE default 8824) and X-Admin-Key header. Polls GET /admin/new every 5s (like gateway shouldNotifyAdmin 30s cooldown), surfaces PendingDeviceCard with Approve flow pre-filling AddDeviceDialog validated by zod MAC_REGEX / HEX_1024 / HEX_64. Masks PUF and publicKey with Reveal/Hide, confirms DELETE /client/device?id= via AlertDialog, and drives gateway POST /admin/block / DELETE /admin/block?mac= (+ GET / blockedDevices) with gatewayFetch and CORS (gateway/src/app.ts:14). Dashboard shows pending from GET /admin/retry and blockedDevices.length.
Self-service UI on Next 15 :9002 with client/src/lib/api.ts:1 (NEXT_PUBLIC_API_BASE default 3000). Same server API via next.config.ts:4 rewrites (/client/* and /admin/* → API_BASE) so 9002 never hardcodes 6767. Polls GET /admin/new with admin:true every 5s, enrolls via POST /client/device, deletes via DELETE /client/device?id= (proxy-safe, server/src/routers/client/device.ts:26 supports ?id=), paginates device cards with usePagination (8/page, client/src/hooks/use-pagination.ts:1). Shows gateway GET / blocked count read-only with note Client is read-only for blocks — use admin (8080). Masks PUF in lastRegistered payload and validates deviceName (1–64 chars) before enroll.
blockchain/contracts/HashStorage.sol stores Record { sender, timestamp, hash, metadata } in records with storeHash(string hash, string metadata) and event HashStored. Deployment targets Polygon Amoy. The server loads the ABI from blockchain/artifacts/contracts/HashStorage.sol/HashStorage.json and uses ethers.JsonRpcProvider and Wallet.
- Post-quantum confidentiality with ML-KEM-768 encapsulation plus HKDF-derived AES-256-GCM. Shared secret never used directly.
- Device identity via SRAM PUF with exact length enforcement (prevents truncation to 2-char match) and Hamming threshold 350 bits out of 4096 for noise tolerance.
- Fail-closed gateway: metadata fetch failure or timeout yields 502 and does not forward.
- No secrets in repository: WiFi credentials and hosts live in
esp/include/config.hand root.env.ADMIN_KEYgatesserver /admin/*viaX-Admin-Keyandgatewayforwards with the same header when set.CORS_ORIGINSrestricts browser origins. - Bounded logs, body limit 50kb, fire-and-forget chain anchoring so Polygon latency does not block device responses.
Server and gateway share a single .env at the repository root. UIs have their own client/.env and admin/.env (both gitignored, see .env.example). Copy each and fill values.
cp .env.example .env
cp admin/.env.example admin/.env
cp client/.env.example client/.env| Variable | Default | Used by | Description |
|---|---|---|---|
SERVER_PORT |
3000 | server | Express listen port |
CORS_ORIGINS |
http://localhost:8080,http://localhost:9002 |
server | Comma list of allowed origins, empty means permissive |
ADMIN_KEY |
empty (open) | server, gateway | When set, X-Admin-Key required for /admin/* and gateway uses it for /admin/new |
GATEWAY_PORT |
8824 | gateway | Gateway listen port |
FORWARD_BASE |
http://localhost:3000 | gateway | Server base URL the gateway forwards to |
BLOCK_THRESHOLD_BITS |
350 | gateway, server shared | Hamming distance to treat two SRAM readings as same device |
GATEWAY_FETCH_TIMEOUT_MS |
5000 | gateway | Timeout for gateway to server fetches |
AMOY_RPC_URL |
https://rpc-amoy.polygon.technology | server, blockchain | Polygon Amoy RPC |
PRIVATE_KEY |
empty | server, blockchain | EOA for HashStorage.storeHash; also AMOY_PRIVATE_KEY alias |
CONTRACT_ADDRESS |
empty | server | Deployed HashStorage address; server queues hashes while unset |
VITE_API_BASE |
http://localhost:3000 |
admin | Server base URL, injected into fetch; admin polls /admin/new every 5s |
VITE_GATEWAY_BASE |
http://localhost:8824 |
admin | Gateway base for GET / and /admin/block (CORS enabled in gateway) |
VITE_ADMIN_KEY |
empty | admin | Sent as X-Admin-Key for /admin/* and gateway block; must match root ADMIN_KEY |
NEXT_PUBLIC_API_BASE |
http://localhost:3000 |
client | Server base URL, used by lib/api.ts and next.config.ts rewrites |
NEXT_PUBLIC_GATEWAY_BASE |
http://localhost:8824 |
client | Read-only GET / blocked count display |
NEXT_PUBLIC_ADMIN_KEY |
empty | client | Sent as X-Admin-Key for GET /admin/new poll when set |
ESP firmware uses esp/include/config.h instead of .env:
| Define | Example | Description |
|---|---|---|
WIFI_SSID / WIFI_PASSWORD |
AYULAPTOP | WiFi credentials |
GATEWAY_HOST / GATEWAY_PORT |
10.10.11.3:8824 | Gateway host for /data and /encrypt |
GATEWAY_DATA_PATH / GATEWAY_ENCRYPT_PATH |
/data, /encrypt | Paths appended to gateway host |
SERVER_HOST / SERVER_PORT |
10.10.11.3:3000 | Server host for POST /client/publicKey fetch |
HARDCODED_PUBLIC_KEY |
"" | If non-empty, firmware uses it directly. Leave empty to fetch by mac at runtime (recommended) |
WIFI_CONNECT_TIMEOUT_MS |
15000 | WiFi connect deadline |
HTTP_TIMEOUT_MS |
5000 | HTTPClient timeout |
LOOP_INTERVAL_MS |
5000 | Delay between telemetry loops |
Requirements: Node.js 20 or newer, npm 10+, PlatformIO Core for ESP, and for blockchain an Amoy RPC and funded private key. React 19 is pinned at the monorepo root (overrides) so admin, client, and Next 15 dedupe correctly.
# 1. Install all Node workspaces from root
npm install
# 2. Configure environment
cp .env.example .env
cp admin/.env.example admin/.env
cp client/.env.example client/.env
# edit .env: set PRIVATE_KEY, CONTRACT_ADDRESS after deploy, ADMIN_KEY, etc.
# edit admin/.env and client/.env: set VITE_* / NEXT_PUBLIC_* to http://localhost:3000 and ADMIN_KEY if set
# 3. Configure ESP
cp esp/include/config.h.example esp/include/config.h
# edit esp/include/config.h: set WIFI_SSID, GATEWAY_HOST, etc.# Development with auto-reload
npm run server:dev
# Build and start compiled output
npm run build -w server && npm start -w server
# Direct
npm run dev -w server # nodemon + ts-node src/index.ts
npm start -w server # node dist/index.jsServer starts on SERVER_PORT, registers file-system routes under src/routers, and starts the blockchain retry interval (30s). Logs warn if PRIVATE_KEY or CONTRACT_ADDRESS are unset.
npm run build -w gateway && npm start -w gateway
# or dev
npm run dev -w gateway # ts-node/esm src/index.ts
# or monorepo helper
npm run gateway:startGateway listens on GATEWAY_PORT, loads data.json (blockedDevices only, migrates old files), proxies to FORWARD_BASE, and enables CORS for admin/client.
# Development :8080 with proxy for /client and /admin to VITE_API_BASE
npm run admin:dev # or npm run dev -w admin
# Production bundle (chunked: vendor/query/ui)
npm run admin:build # vite build -> dist/
npm run preview -w admin # serve dist on 4173Admin polls GET /admin/new every 5s, approves via PendingDeviceCard → AddDeviceDialog → POST /client/device, blocks via POST /admin/block (1024 hex sram) on VITE_GATEWAY_BASE, paginates Logs and Devices, and shows masked PUF/publicKey with Reveal/Hide. Set VITE_ADMIN_KEY to match root ADMIN_KEY or 401 Unauthorized is shown.
# Development :9002 with Turbopack and rewrites /client/* /admin/* → NEXT_PUBLIC_API_BASE
npm run client:dev # or npm run dev -w client
# Production (strict types, no ignoreBuildErrors)
npm run client:build # next build
npm run start -w client # next start on 3000 (or 9002 via -p)Client is self-service: polls GET /admin/new (admin:true header) every 5s, enrolls POST /client/device, lists GET /client/metadata (paginated 8/page), views devices/[id] (GET /client/message?id=), shows GATEWAY BLOCKED: n read-only and validates deviceName 1–64 chars. No gateway block privileges (use admin).
Run all three UIs together:
npm run server:dev & npm run gateway:start & npm run admin:dev & npm run client:dev
# server 3000, gateway 8824, admin 8080, client 9002cd blockchain
npm run compile # hardhat compile → artifacts/
npm run deploy # hardhat run scripts/deploy.js --network amoy
# copy printed address into CONTRACT_ADDRESS in root .env and restart servercd esp
# ESP32 (default environment)
pio run -e esp32
pio run -e esp32 -t upload --upload-port /dev/cu.usbserial-0001
pio device monitor -b 115200
# ESP8266
pio run -e esp8266
pio run -e esp8266 -t upload --upload-port /dev/cu.wchusbserial-*The firmware prints PUF hex length, connect status, encrypt result, and data POST response. On first boot for an unknown device the gateway returns 403 pending_approval and notifies POST /admin/new. Register the device via the server, then the next loop will be verified and forwarded:
curl -s http://localhost:3000/admin/new | jq
curl -s -X POST http://localhost:3000/client/device \
-H 'Content-Type: application/json' \
-d '{"name":"sensor-01","puf":"<1024 hex from logs>","mac":"AA:BB:CC:DD:EE:FF","firmwareHash":"<64 hex sha256>"}' | jqAlternatively let the gateway auto-derive firmwareHash as SHA-256(sramHex) when not supplied by the ESP header.
Full request and response shapes are in server/README.md. Summary:
POST /client/device{ name, puf, mac, firmwareHash }→ 201DevicewithpublicKeyDELETE /client/device?id=10001→ 200 (query, proxy-safe; body{id}still supported)POST /client/encrypt{ publicKey or mac or id, plaintext }→ 200{ kyberCiphertextBase64, ivBase64, payloadCiphertextBase64 }POST /client/message{ id, data or payloadCiphertextBase64, kyberCiphertextBase64, ivBase64 }→ 201Message { data, timestamp, hash, sender, hashStored }GET /client/message?id=10001→ 200Message[]GET /client/metadata→ 200Device[]POST /client/publicKey{ mac }→ 200{ publicKey }GET /admin/logs→ 200{ devices, logs }(admin auth whenADMIN_KEYset)GET /admin/new→ 200NewDeviceRecord | nullPOST /admin/new{ mac, puf, firmwareHash: 64 hex }→ 200{ newDeviceDetected }GET /admin/retry→ 200{ pending }POST /admin/retry→ 200{ retried, pending }
Gateway proxies:
POST /encrypt→FORWARD_BASE/client/encryptPOST /data→ validates PUF thenFORWARD_BASE/client/message(headersX-MAC-Address,X-SRAM-Data, optionalX-Firmware-Hash)POST /admin/block{ sram: 1024 hex, mac }→ 201 (Hamming-aware block,X-Admin-Keywhen set)DELETE /admin/block?mac=&sram=→ removes block entry (query, proxy-safe)GET /→{ status, blockedDevices }(CORS enabled, read by adminuseGatewayStatusand client read-only)
- Server retry interval runs every 30 seconds for
pendingHashes.POST /admin/retrycan be called manually to drain the queue and inspectpendingcount.GET /admin/logsshowsHASH_STORED,HASH_STORE_FAILED,HASH_RETRYevents. - Gateway pending approvals are rate-limited. Repeated
POST /datafrom the same unknown PUF within 30 seconds reuses the firstPOST /admin/newnotification and still returns 403 until the device is created on the server. - Logs are in-memory bounded to 10,000 entries. Restarting server or gateway clears devices and logs; gateway additionally clears
data.jsononly forblockedDevices. Persisting devices requires movingrepos/DeviceRepositoryto a real store (interface already exists for that swap).
HashStorage artifact not found→ runnpm run compile -w blockchain.Missing AMOY_PRIVATE_KEY or PRIVATE_KEY→ setPRIVATE_KEYin root.env.- Gateway
502 Metadata verification failed→ checkFORWARD_BASEpoints to a running server andSERVER_PORTmatches. Gateway is fail-closed by design. Invalid SRAM hex payload. Expected 1024 hex chars→ ESPpuf_toHex()must produce uppercase hex without separators. Check Serial PUF dump length.- ESP
Encrypt failedor404 Device not found→ device not yet created viaPOST /client/device. CheckGET /admin/newfor the pending PUF and register it.