OXRecorder::OXSecurity
Instance-based AES-256-CBC encryption for strings and binary data
Overview
OXSecurity encrypts and decrypts data with
AES-256-CBC (PKCS#7 padding). It is
instance-based: you construct one object per purpose, each
with its own key and IV — for example one instance for local-file storage
and a separate instance, with different secrets, for talking to a remote
server. A future OXSecurityManager will bundle several
instances behind one helper.
-
Binary API —
encryptBinary()/decryptBinary()work on raw bytes: images, audio buffers, anything. -
String API (device only) —
encrypt()/decrypt()take and return an ArduinoString; ciphertext is base64-encoded so it is safe to store or transmit as text. - Production crypto is mbedTLS (bundled with the ESP32-S3 core, hardware-accelerated). No hand-rolled cipher ships.
Declared in include/OXSecurity.h; portable logic in
src/OXSecurity.cpp; the mbedTLS backend in
src/crypto/MbedAesCbc.cpp. Diagnostics go through
OXLog.
⚠ Security notes
Read these before using the class. They are deliberate trade-offs of the chosen configuration:
- Fixed IV. The IV passed at construction is reused for every call on that instance. Reusing an IV with the same key means identical plaintexts produce identical ciphertext, which leaks equality. Use distinct keys/IVs per purpose, and prefer encrypting distinct data. If you need to hide repetition, give each record its own instance/IV or move to a random-IV scheme.
-
No authentication. CBC provides confidentiality but
not integrity — it cannot detect tampering. A successful
decryptdoes not prove the data is genuine. If you need tamper detection, add a MAC (e.g. HMAC-SHA256) over the ciphertext or move to AES-GCM. -
Key management is yours.
OXSecurityholds the key in RAM for its lifetime; it does not derive, store, or rotate keys. Keep keys out of source control and consider the ESP32 secure storage / eFuse for provisioning.
Constants
| Constant | Value | Meaning |
|---|---|---|
kKeyBytes | 32 | AES-256 key length. Only this size is accepted. |
kIvBytes | 16 | IV length (one AES block). |
kBlockBytes | 16 | AES block size. |
API reference
Construction & configuration
OXSecurity()
Creates an unconfigured instance; call setKey() before use.
OXSecurity(const uint8_t key[32], const uint8_t iv[16])
Creates and configures in one step. The key and IV are copied into the instance.
bool setKey(const uint8_t key[32], const uint8_t iv[16])
Sets or replaces the 32-byte key and 16-byte IV. Returns false if either pointer is null.
bool isConfigured() const
True once a key and IV have been set.
Binary API
static size_t maxEncryptedLen(size_t plainLen)
Worst-case ciphertext size for plainLen bytes: rounded up to
the next multiple of 16, always adding a full block when already aligned
(PKCS#7 always appends 1–16 bytes). Use it to size your output buffer.
bool encryptBinary(const uint8_t *in, size_t inLen, uint8_t *out, size_t outCap, size_t &outLen) const
Encrypts inLen bytes into out and sets
outLen to the ciphertext size. Returns false
(writing nothing past outCap) if the instance is
unconfigured, no cipher is installed, or the buffer is too small.
| Parameter | Type | Meaning |
|---|---|---|
in | const uint8_t * | Plaintext bytes to encrypt (image, audio, struct, any blob). |
inLen | size_t | Number of plaintext bytes. |
out | uint8_t * | Destination for ciphertext. Size it with maxEncryptedLen(inLen). |
outCap | size_t | Capacity of out; the call fails rather than overflow it. |
outLen | size_t & | Out-param set to the actual ciphertext length (a multiple of 16) on success; 0 on failure. |
bool decryptBinary(const uint8_t *in, size_t inLen, uint8_t *out, size_t outCap, size_t &outLen) const
Decrypts inLen bytes (must be a non-zero multiple of 16),
validates and strips PKCS#7 padding, and sets outLen to the
recovered length. Returns false on a bad length, invalid
padding, a too-small buffer, or a wrong key (which usually surfaces as
invalid padding).
| Parameter | Type | Meaning |
|---|---|---|
in | const uint8_t * | Ciphertext from a prior encryptBinary(). |
inLen | size_t | Ciphertext length; must be a non-zero multiple of 16. |
out | uint8_t * | Destination for the recovered plaintext (at most inLen bytes). |
outCap | size_t | Capacity of out; must be at least inLen. |
outLen | size_t & | Out-param set to the recovered plaintext length on success; 0 on failure. |
String API device only
String encrypt(const String &plaintext) const
Encrypts the text and returns base64-encoded ciphertext. Empty String on failure.
String decrypt(const String &base64Ciphertext) const
Decodes base64, decrypts, and returns the plaintext. Empty String on failure.
Cipher seam
static void setCipher(IAesCbc *cipher)
static IAesCbc *cipher()
Installs/queries the raw AES primitive. On-device the mbedTLS backend registers itself automatically at startup, so you normally never call this. The host unit tests use it to inject a reference cipher. The pointer is shared by all instances and is not owned here.
Base64 helpers
static size_t base64EncodedLen(size_t rawLen)
static size_t base64DecodedMaxLen(size_t b64Len)
static size_t base64Encode(const uint8_t *in, size_t inLen, char *out, size_t outCap)
static int base64Decode(const char *in, size_t inLen, uint8_t *out, size_t outCap)
Standard RFC 4648 base64 (with = padding). Used by the
String API and available for transport encoding. base64Encode
returns the encoded length (excluding the NUL terminator) or 0 on failure;
base64Decode returns the decoded byte count or -1
on malformed input or a small buffer. Base64 is an encoding, not
encryption — it provides no confidentiality on its own.
Example
Try it
Paste the sketch into src/main.cpp, then
pio run -e esp32-s3-super-mini -t upload && pio device monitor.
The AES math, PKCS#7 padding, and base64 are all verified on the host by
pio test -e native (including a NIST known-answer vector) — no
hardware needed to trust the crypto.
Two instances with different secrets — one for local files, one for the
server. The key and IV are generated at runtime from the hardware RNG and
loaded with setKey(), rather than baked in at compile time:
#include <Arduino.h>
#include <esp_random.h>
#include <string.h>
#include "OXSecurity.h"
#include "OXLog.h"
using OXRecorder::OXSecurity;
using OXRecorder::OXLog;
// Construct unconfigured; the key and IV are generated at runtime and loaded
// with setKey() below — nothing secret is baked in at compile time.
OXSecurity diskCrypto; // for files on flash/SD
OXSecurity serverCrypto; // for server comms
// Fill `buf` with cryptographically strong random bytes from the ESP32 RNG.
// esp_random() is hardware-backed once RF (WiFi/BT) is active; otherwise seed
// it from an entropy source before relying on it.
void randomBytes(uint8_t *buf, size_t len) {
for (size_t i = 0; i < len; i += 4) {
uint32_t r = esp_random();
size_t n = (len - i < 4) ? (len - i) : 4;
memcpy(buf + i, &r, n);
}
}
void setup() {
OXLog::begin(115200);
// --- Generate keys/IVs at runtime, then load them ---
// On first boot, generate and persist (e.g. to internal flash via
// OXFileManager); on later boots, load the saved bytes instead of
// regenerating, or files written earlier could not be decrypted.
uint8_t diskKey[32], diskIv[16];
randomBytes(diskKey, sizeof(diskKey));
randomBytes(diskIv, sizeof(diskIv));
diskCrypto.setKey(diskKey, diskIv);
// A server session key might instead arrive from a key-exchange handshake;
// here we generate one locally for illustration.
uint8_t serverKey[32], serverIv[16];
randomBytes(serverKey, sizeof(serverKey));
randomBytes(serverIv, sizeof(serverIv));
serverCrypto.setKey(serverKey, serverIv);
// --- String round trip (base64 ciphertext) ---
String token = serverCrypto.encrypt("auth:device-42");
OXLog::info("encrypted token: %s", token.c_str());
String back = serverCrypto.decrypt(token);
OXLog::info("recovered: %s", back.c_str());
// --- Binary round trip (e.g. an audio or image buffer) ---
uint8_t audio[100];
memset(audio, 0xA5, sizeof(audio));
size_t cap = OXSecurity::maxEncryptedLen(sizeof(audio));
uint8_t cipher[128];
size_t cipherLen = 0;
if (diskCrypto.encryptBinary(audio, sizeof(audio), cipher, sizeof(cipher),
cipherLen)) {
OXLog::info("encrypted %u bytes -> %u bytes",
(unsigned)sizeof(audio), (unsigned)cipherLen);
// ... write `cipher` to disk via OXFileManager ...
}
uint8_t restored[128];
size_t restoredLen = 0;
if (diskCrypto.decryptBinary(cipher, cipherLen, restored,
sizeof(restored), restoredLen)) {
OXLog::info("decrypted back to %u bytes", (unsigned)restoredLen);
}
}
void loop() {}
Sample output
[18][I] encrypted token: 3sJk0mF1q9c2... (base64)
[19][I] recovered: auth:device-42
[20][I] encrypted 100 bytes -> 112 bytes
[21][I] decrypted back to 100 bytes
100 bytes pad up to 112 (next multiple of 16). The bracketed numbers are
OXLog timestamps.
Encoding a Key/IV as a printable string
A raw 32-byte key contains arbitrary byte values (0x00–0xFF), most of which are not printable and several of which are unsafe in text protocols (NUL, newlines, quotes). To send a key or IV over the network — or store it in JSON, a header, or a log — convert it to a printable form and convert it back on the other side.
Do not make the bytes printable by restricting the random generator to printable characters (e.g. only A–Z0–9). That throws away entropy: a 32-byte key drawn from 64 printable symbols has ~192 bits of strength instead of 256, and a key drawn from the 95 printable ASCII characters is still weaker and biased. Always generate full-entropy bytes, then encode them for transport. Encoding is reversible and loses nothing; restricting the alphabet is irreversible and loses security.
OXSecurity already provides standard base64, which is the
easiest printable, network-safe representation.
Quick version: just print the Key and IV
If all you want is to see the key and IV as base64 text (to log them, copy
them, or drop them into a request), call base64Encode() into a
char buffer and print it. A 32-byte key needs 44 characters
plus a NUL; a 16-byte IV needs 24:
uint8_t key[32], iv[16];
randomBytes(key, sizeof(key)); // full-entropy bytes (helper shown below)
randomBytes(iv, sizeof(iv));
char keyB64[45]; // base64EncodedLen(32) == 44, +1 for NUL
char ivB64[25]; // base64EncodedLen(16) == 24, +1 for NUL
OXSecurity::base64Encode(key, sizeof(key), keyB64, sizeof(keyB64));
OXSecurity::base64Encode(iv, sizeof(iv), ivB64, sizeof(ivB64));
OXLog::info("KEY=%s", keyB64);
OXLog::info("IV=%s", ivB64);
// Or with plain Serial: Serial.printf("KEY=%s\n", keyB64);
Prints e.g. KEY=n2Jq8F0s…Xc1d2Q= and
IV=pQ7r…bZ8= — both printable and safe to transmit.
base64Encode() returns 0 (and writes nothing) if the buffer is
too small, so size it with base64EncodedLen(len) + 1.
Full round trip: encode, send, decode, load
To use the encoded strings on the other side, decode them back to the exact
bytes and call setKey(). The round trip is
generate → base64Encode → (send/store the string)
→ base64Decode → setKey:
#include <Arduino.h>
#include <esp_random.h>
#include <string.h>
#include "OXSecurity.h"
#include "OXLog.h"
using OXRecorder::OXSecurity;
using OXRecorder::OXLog;
// Full-entropy random bytes (see the helper from the previous example).
void randomBytes(uint8_t *buf, size_t len) {
for (size_t i = 0; i < len; i += 4) {
uint32_t r = esp_random();
size_t n = (len - i < 4) ? (len - i) : 4;
memcpy(buf + i, &r, n);
}
}
// Encode raw bytes to a printable base64 Arduino String.
String bytesToBase64(const uint8_t *buf, size_t len) {
char out[128]; // fits base64 of a 32-byte key (44 chars + NUL)
size_t n = OXSecurity::base64Encode(buf, len, out, sizeof(out));
return (n > 0) ? String(out) : String();
}
// Decode a base64 String back into raw bytes. Returns the byte count, or -1.
int base64ToBytes(const String &s, uint8_t *out, size_t outCap) {
return OXSecurity::base64Decode(s.c_str(), s.length(), out, outCap);
}
void setup() {
OXLog::begin(115200);
// 1. Generate a full-entropy key + IV.
uint8_t key[32], iv[16];
randomBytes(key, sizeof(key));
randomBytes(iv, sizeof(iv));
// 2. Encode to printable strings — safe to send over the network or store.
String keyStr = bytesToBase64(key, sizeof(key));
String ivStr = bytesToBase64(iv, sizeof(iv));
OXLog::info("key (base64): %s", keyStr.c_str());
OXLog::info("iv (base64): %s", ivStr.c_str());
// ... transmit keyStr / ivStr to the peer, or persist them ...
// 3. On the peer (or next boot): decode the strings back to exact bytes.
uint8_t keyBack[32], ivBack[16];
if (base64ToBytes(keyStr, keyBack, sizeof(keyBack)) == 32 &&
base64ToBytes(ivStr, ivBack, sizeof(ivBack)) == 16) {
OXSecurity crypto;
crypto.setKey(keyBack, ivBack); // identical to the original key/IV
OXLog::info("key/iv decoded and loaded");
} else {
OXLog::error("failed to decode key/iv");
}
}
void loop() {}
Sample output
[15][I] key (base64): n2Jq8F0s ... 44 chars total ... Xc1d2Q=
[16][I] iv (base64): pQ7r ... 24 chars total ... bZ8=
[17][I] key/iv decoded and loaded
A 32-byte key encodes to 44 base64 characters; a 16-byte IV to 24. Both are printable and safe to put in JSON, an HTTP header, or a query string.
Hex as an alternative
If the peer or protocol expects hex rather than base64, the same generate-then-encode pattern applies — hex is just a different printable encoding (2 characters per byte, so a 32-byte key becomes 64 hex chars):
// Encode raw bytes to a lowercase hex String.
String bytesToHex(const uint8_t *buf, size_t len) {
static const char *d = "0123456789abcdef";
String s;
s.reserve(len * 2);
for (size_t i = 0; i < len; ++i) {
s += d[buf[i] >> 4];
s += d[buf[i] & 0x0F];
}
return s;
}
Whichever encoding you choose, the rule is the same: the bytes fed to
setKey() must be the exact 32-byte key and 16-byte IV. Encode
only for transport or storage, and always decode back to the raw bytes
before use.
Planned: OXSecurityManager
OXSecurity is intentionally instance-based so a future
OXSecurityManager can hold a local-disk instance and a
server-comms instance (each with its own key/IV) and expose convenience
wrappers — e.g. encryptForServer() /
decryptFromDisk() — without callers juggling two objects.