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.

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 decrypt does 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. OXSecurity holds 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

ConstantValueMeaning
kKeyBytes32AES-256 key length. Only this size is accepted.
kIvBytes16IV length (one AES block).
kBlockBytes16AES 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.

ParameterTypeMeaning
inconst uint8_t *Plaintext bytes to encrypt (image, audio, struct, any blob).
inLensize_tNumber of plaintext bytes.
outuint8_t *Destination for ciphertext. Size it with maxEncryptedLen(inLen).
outCapsize_tCapacity of out; the call fails rather than overflow it.
outLensize_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).

ParameterTypeMeaning
inconst uint8_t *Ciphertext from a prior encryptBinary().
inLensize_tCiphertext length; must be a non-zero multiple of 16.
outuint8_t *Destination for the recovered plaintext (at most inLen bytes).
outCapsize_tCapacity of out; must be at least inLen.
outLensize_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.