Lightweight stream cipher library for Arduino / ESP32 with matching server-side implementations in Node.js and Python. Designed for easy encrypted TCP communication between microcontrollers and web servers. Use this if you want to avoid heavy TLS overhead and not too nitpicky about security. It's crackable, but it would take someone a huge effort to crack it.
- Byte-at-a-time encryption, no block size, no padding, no buffering
- Tiny footprint, runs on ATmega328P or ESP32
- Cross-platform, identical output on C++, JavaScript, and Python
- Independent encrypt / decrypt streams (full-duplex)
- Download or clone this repository into your Arduino libraries folder:
Documents/Arduino/libraries/StreamCipher/ - Restart the Arduino IDE. The library appears under Sketch → Include Library → StreamCipher.
lib_deps = StreamCipher#include <StreamCipher.h>
StreamCipher cipher;
void setup() {
Serial.begin(115200);
cipher.setKey("MySecretKey");
// Encrypt
cipher.encStart();
byte enc = cipher.encChar('A');
// Decrypt
cipher.decStart();
char dec = cipher.decChar(enc); // 'A'
}Constructor. Creates an instance with a zeroed key. Call setKey() before encrypting.
Derives the internal 16-byte key from a passphrase (2–15 characters). Automatically calls encStart() and decStart().
Reset the encrypt or decrypt state machine to the beginning of the keystream. Call these when you start a new message or a new TCP connection so both sides are in sync.
Encrypt one plaintext byte. Returns the ciphertext byte. Advances internal encrypt state.
Decrypt one ciphertext byte. Returns the plaintext character. Advances internal decrypt state.
Encrypt a null-terminated string. Returns a malloc'd byte array (caller must free() it). The returned array is null-terminated for convenience but the encrypted bytes may contain 0x00.
Decrypt an array of len ciphertext bytes. Returns a malloc'd null-terminated string (caller must free() it).
The included examples and servers use a simple length-prefixed binary frame protocol:
┌──────────────────┬──────────────────────────┐
│ 2 bytes (BE) │ N encrypted bytes │
│ message length │ (encrypt each byte) │
└──────────────────┴──────────────────────────┘
- The sender writes a 2-byte big-endian length, then N encrypted bytes.
- The receiver reads the 2-byte header to know how many bytes to expect, then decrypts them.
- Both sides must call
encStart()/decStart()(or create a fresh cipher) at the start of each TCP connection.
This keeps framing simple and works reliably over TCP's byte-stream model.
Before any encryption begins, both sides perform a sync handshake to ensure cipher states are aligned. Each side sends three backspace bytes (\b\b\b, i.e. 0x08 0x08 0x08) as raw plain text immediately after the TCP connection is established, then waits until it receives the same three bytes from the other side. Only after both sides have exchanged the sync marker do they call encStart() / decStart() and begin encrypted communication.
This eliminates timing-related desync issues where one side might start encrypting before the other is ready.
TCP connected
┌─ ESP32: send 0x08 0x08 0x08 → wait for 0x08 0x08 0x08
└─ Server: send 0x08 0x08 0x08 → wait for 0x08 0x08 0x08
Both sides: sync received → encStart() / decStart()
To prevent the "two-time pad" problem (identical keystreams when reconnecting with the same key), both sides send a random nonce frame as the very first encrypted message after the sync handshake. The server generates 8 cryptographically random bytes (crypto.randomBytes / os.urandom). The Arduino builds its nonce from two millis() calls, split into 8 bytes in big-endian order so the least-predictable low bytes come last and maximally poison the cipher's accumulator state.
Each side must receive and decrypt the other's nonce before sending any real messages — this advances the decrypt state to match the other side's encrypt state, keeping both streams in sync.
After sync handshake:
1. Server: sends 8-byte encrypted nonce frame
2. Arduino: blocks until server nonce received & decrypted (dec +8)
3. Arduino: sends 8-byte encrypted nonce frame + greeting
4. Server: receives Arduino nonce, decrypts (dec +8), activates connection
5. Both sides: enc and dec states are now aligned → normal messaging
The nonce frame uses the same length-prefixed format as regular messages ([0x00, 0x08, <8 encrypted bytes>]). The server dashboard displays nonce bytes in hex (e.g. [0xAB][0x3F]...) so you can verify the exchange is happening.
Standalone encryption/decryption demo, no network required. Encrypts a string, decrypts it, and prints the results to Serial.
Location: examples/BasicUsage/BasicUsage.ino
Full bidirectional encrypted TCP communication between an ESP32 and a server. Sends periodic telemetry (uptime, heap, RSSI) and forwards Serial Monitor input to the server. Receives and displays encrypted replies.
On each new connection the sketch performs a sync handshake (\b\b\b exchange) to align cipher state, then a nonce exchange (8 random encrypted bytes each way) to prevent keystream reuse across sessions. The Arduino blocks until both the sync and the server's nonce are received before sending any data.
Location: examples/TCP_secure_connection/TCP_secure_connection.ino
Configuration:
const char* ssid = "WiFi_Name";
const char* wifiPassword = "WiFi_Pass";
const char* serverIP = "192.168.137.1"; // default windows hotspot IP
const uint16_t serverPort = 2096; // make sure that the firewall allows it
const char* sharedSecret = "mY_$ecret_keY";Both servers provide the same functionality:
- TCP server on port 2096: receives and decrypts ESP32 messages, allow firewall policy.
- HTTP dashboard on port 3000: live message log + send messages back to ESP32. No need to allow via firewall if you can open the webpage locally.
cd server_side/node_express_server
npm install
node server.jsOpen http://localhost:3000 to view the dashboard.
Files:
| File | Description |
|---|---|
server.js |
Express HTTP + TCP server with web dashboard |
streamcipher.js |
JavaScript StreamCipher implementation |
package.json |
npm dependencies |
cd server_side/python_flask_server
pip install -r requirements.txt
python server.pyOpen http://localhost:3000 to view the dashboard.
Files:
| File | Description |
|---|---|
server.py |
Flask HTTP + TCP server with web dashboard |
streamcipher.py |
Python StreamCipher implementation |
requirements.txt |
pip dependencies |
All three implementations produce identical ciphertext for the same key and plaintext. The built-in test uses key mY_$ecret_keY and plaintext Hello123:
Expected bytes: [202, 210, 1, 16, 20, 39, 199, 252]
Run the self-tests:
# JavaScript
node -e "require('./streamcipher').StreamCipher; /* see test() in file */"
# Python
python streamcipher.py
# Output: All StreamCipher tests passed!StreamCipher/
├── StreamCipher.h # Library header
├── StreamCipher.cpp # Library implementation
├── library.properties # Arduino library metadata
├── keywords.txt # IDE syntax highlighting
├── LICENSE # GPLv3
├── README.md
├── examples/
│ ├── BasicUsage/
│ │ └── BasicUsage.ino # Standalone encrypt/decrypt demo
│ └── TCP_secure_connection/
│ └── TCP_secure_connection.ino # ESP32 TCP demo
└── server_side/
├── node_express_server/
│ ├── server.js # Node.js Express + TCP server
│ ├── streamcipher.js # JS cipher implementation
│ └── package.json
└── python_flask_server/
├── server.py # Python Flask + TCP server
├── streamcipher.py # Python cipher implementation
└── requirements.txt
- The shared secret must be kept private, obviously. Anyone with the key can decrypt the traffic.
- Both sides must reset cipher state (
encStart()/decStart()) at the same point. The included examples handle this automatically via the sync handshake. - The nonce exchange ensures every session starts at a unique, unpredictable keystream offset — even if the same key is reused across connections. Always send a nonce as the first encrypted data after
encStart(). - Avoid using generic/predictable preambles after the nonce. For example, don't start with "hello" or "GET / HTTP/1.1". The more unpredictable the beginning, the better the security for the whole session as the state accumulates.
- The server dashboards display non-ASCII / non-printable bytes as hex (e.g.
[0xAB]) so you can verify nonce frames are being exchanged correctly.
This section gives an honest assessment of the cipher's strength and what an attacker would need.
The cipher's internal state at any point in a stream depends on three things: the 16-byte derived key (128 bits), an 8-bit counter _enc_i, an 8-bit key-index _enc_c, and an 8-bit accumulator _enc_state. The keystream byte is generated through addition, XOR, and rotation mixing before being XORed with plaintext, so the output is not a simple lookup.
| Parameter | Size |
|---|---|
| Derived key | 16 bytes (128 bits) |
Key-index counter c |
8 bits (cycles over 16 positions) |
Byte counter i |
8 bits |
| State accumulator | 8 bits |
_cip constant |
8 bytes (public, embedded in StreamCipher.cpp, change it if you want) |
The passphrase can be 2–15 printable ASCII characters. For a random passphrase using the full printable range (~95 characters), the search space is:
| Length | Combinations | Time at 1 billion tries/sec |
|---|---|---|
| 6 chars | ~735 billion | ~12 minutes |
| 8 chars | ~6.6 quadrillion | ~77 days |
| 10 chars | ~59 quintillion | ~1,900 years |
| 12 chars | ~540 sextillion | ~17 million years |
| 15 chars | ~46 octillion | ~1.5 billion years |
Bottom line: A short or dictionary-based passphrase (e.g. hello, 1234) can be brute-forced in seconds. A random 10+ character passphrase with symbols is not practical to brute-force. If you do want to use a rememberable english phrase then I suggest you change _cip at the begining of StreamCipher.cpp, which will help garble your memorable phrase.
The key derivation produces a 16-byte (128-bit) key. Brute-forcing the derived key directly means searching
If an attacker knows (or can guess) the plaintext of a captured message, they can recover the keystream for those byte positions by XORing plaintext with ciphertext. However:
- The keystream depends on the derived key and a running accumulator (
_enc_state += ks). Each keystream byte changes the internal state, so knowing one keystream byte doesn't trivially reveal the next. - To recover the key from the keystream, the attacker must solve a system of non-linear equations involving addition, XOR, and bit-rotation across 16 key bytes, not a simple table lookup.
- But: the internal state is only 8 bits wide. If the attacker knows ~256+ consecutive plaintext bytes, they could try all 256 possible
_enc_statevalues at the boundary and work backwards to narrow the key. This is the cipher's weakest point.
Mitigation: Don't send long messages with guessable content. Vary your payloads. The security advice about avoiding predictable preambles applies here. If the first few bytes are unpredictable, the attacker can't get a foothold.
- State width: The accumulator is only 8 bits, so after at most 256 byte positions the internal state starts cycling. A sophisticated attacker with enough ciphertext (thousands of bytes from the same session) could detect statistical biases in the keystream.
- Two-time pad without nonce: Every connection with the same key would start with the same keystream if no nonce were used. If the attacker captures two such sessions, the XOR of both ciphertexts cancels the keystream and reveals the XOR of both plaintexts (a classic "two-time pad" attack). The included examples mitigate this with a sync handshake followed by a nonce exchange at the start of each connection — the random nonce bytes advance the cipher to a unique offset every time. If you roll your own protocol, always send random nonce bytes as the first encrypted data after
encStart(). - Lack of authentication: There is no MAC or HMAC, the cipher provides confidentiality only. An attacker who can intercept and modify traffic could flip ciphertext bits to flip the corresponding plaintext bits (bit-flipping attack).
| Attacker | Effort | Outcome |
|---|---|---|
| Wireshark sniffer | Low | Can capture traffic, but can't decrypt without the key |
| Determined attacker with known plaintext | Moderate | Could recover key if they guess ~256+ bytes of a session and the passphrase is short |
| Cryptanalyst with many captured sessions | High | Could exploit two-time pad if nonce is not used and sessions start with identical content; could detect statistical biases in long streams |
This cipher is not designed to resist a dedicated cryptanalyst — it's designed to be a practical encryption layer that stops passive eavesdropping and raises the bar significantly above plaintext. For IoT sensor data, hobby projects, and non-critical telemetry, it provides meaningful protection with near-zero overhead. Use a strong random passphrase (10+ characters with symbols), always use the sync + nonce protocol (or your own equivalent), keep your messages varied, and it will serve you well.