Skip to content

tabahi/StreamCipher

Repository files navigation

StreamCipher

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)

Installation

Arduino IDE

  1. Download or clone this repository into your Arduino libraries folder:
    Documents/Arduino/libraries/StreamCipher/
    
  2. Restart the Arduino IDE. The library appears under Sketch → Include Library → StreamCipher.

PlatformIO

lib_deps = StreamCipher

Quick Start (Arduino)

#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'
}

API Reference

StreamCipher()

Constructor. Creates an instance with a zeroed key. Call setKey() before encrypting.

void setKey(const char *secret)

Derives the internal 16-byte key from a passphrase (2–15 characters). Automatically calls encStart() and decStart().

void encStart() / void 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.

byte encChar(char c)

Encrypt one plaintext byte. Returns the ciphertext byte. Advances internal encrypt state.

char decChar(byte c)

Decrypt one ciphertext byte. Returns the plaintext character. Advances internal decrypt state.

byte* encString(char *str)

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.

char* decString(byte *data, uint8_t len)

Decrypt an array of len ciphertext bytes. Returns a malloc'd null-terminated string (caller must free() it).

TCP Communication Protocol

The included examples and servers use a simple length-prefixed binary frame protocol:

┌──────────────────┬──────────────────────────┐
│  2 bytes (BE)    │  N encrypted bytes       │
│  message length  │  (encrypt each byte)     │
└──────────────────┴──────────────────────────┘
  1. The sender writes a 2-byte big-endian length, then N encrypted bytes.
  2. The receiver reads the 2-byte header to know how many bytes to expect, then decrypts them.
  3. 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.

Sync Handshake

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()

Nonce Exchange

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.

Examples

BasicUsage

Standalone encryption/decryption demo, no network required. Encrypts a string, decrypts it, and prints the results to Serial.

Location: examples/BasicUsage/BasicUsage.ino

ESP32 TCP Demo

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";

Server-Side Implementations

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.

Node.js (Express)

cd server_side/node_express_server
npm install
node server.js

Open 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

Python (Flask)

cd server_side/python_flask_server
pip install -r requirements.txt
python server.py

Open 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

Cross-Platform Verification

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!

Project Structure

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

Security Notes

  • 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.

What Would It Take to Crack It?

This section gives an honest assessment of the cipher's strength and what an attacker would need.

What the attacker is up against

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)

Brute-force the passphrase

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.

Brute-force the derived key directly

The key derivation produces a 16-byte (128-bit) key. Brute-forcing the derived key directly means searching $2^{128}$ possibilities, that's ~$3.4 \times 10^{38}$ keys. At 1 billion keys/sec, that's about $10^{22}$ years. This is not feasible.

Known-plaintext attack

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_state values 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.

Statistical / cryptanalytic attacks

  • 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).

Realistic threat assessment

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

TLDR

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.