How OXSecurity Works

A walkthrough of the design and internals

What this page is

The API reference tells you what each method does. This page explains how the class is built and why — how it stays testable without shipping its own crypto, how PKCS#7 and base64 fit together, and how the device and host builds diverge. Read it before changing the cipher, the padding, or the encoding.

The central tension: testable, but no hand-rolled crypto

Two requirements pull in opposite directions:

The resolution: split the class into a thin, swappable cipher primitive and a thick layer of portable logic around it. Only the primitive differs between device and host.

OXSecurity  (src/OXSecurity.cpp — portable: PKCS#7, base64, sizing, validation)
   │  one injected primitive, stateless (key + IV passed per call)
   ▼
IAesCbc  (pure virtual — raw AES-256-CBC on 16-byte-aligned buffers, no padding)
   ├─ MbedAesCbc   src/crypto/MbedAesCbc.cpp        → mbedTLS (device only)
   └─ RefAesCbc    test/test_oxsecurity/RefAesCbc.h → textbook AES test oracle

IAesCbc is deliberately tiny: encrypt or decrypt a buffer whose length is a multiple of 16, given a 32-byte key and a 16-byte IV that it mutates in place (CBC chaining). Everything else lives in the portable core, so the interesting, bug-prone logic is exercised by host tests while the actual AES math on-device comes from a library we trust.

Trusting the host tests: a reference oracle, pinned to NIST

On the host there is no mbedTLS, so the tests inject RefAesCbc, a compact textbook AES-256-CBC. That raises an obvious question: why trust a test-only cipher? Because the suite first pins it to a published NIST SP 800-38A AES-256-CBC known-answer vector before relying on it:

// Known key/IV/plaintext/ciphertext straight from the NIST document.
g_cipher.encrypt(kKey, iv, kPlainBlock, out, 16);
TEST_ASSERT_EQUAL_MEMORY(kCipherBlock, out, 16);  // must match exactly

If RefAesCbc reproduces the standard's ciphertext bit-for-bit, it is a correct AES-256 implementation, and the remaining tests can use it as an oracle for OXSecurity's padding, buffer, and base64 logic. The on-device MbedAesCbc is validated separately — it is the same standard, computed by the vendor library — and you can run the same vector on hardware as a smoke test.

PKCS#7 padding: why ciphertext is always longer

CBC only processes whole 16-byte blocks, so plaintext must be padded to a block boundary. PKCS#7 appends N bytes each equal to N, where N is 1–16. The key subtlety: padding is always added, even when the input is already block-aligned — otherwise the decryptor could not tell padding from data. So a 16-byte input becomes 32 bytes. That is exactly what maxEncryptedLen() encodes:

size_t OXSecurity::maxEncryptedLen(size_t plainLen) {
  return ((plainLen / 16) + 1) * 16;  // always at least one pad block
}

On encrypt, the core copies the plaintext into the output buffer and fills the tail with the pad value, then hands the now-aligned buffer to the primitive:

uint8_t padValue = padded - inLen;        // 1..16
memcpy(out, in, inLen);
memset(out + inLen, padValue, padValue);  // e.g. 0x04 0x04 0x04 0x04

On decrypt, the core reads the last byte to learn N, then verifies all N trailing bytes equal N before trimming them. This check is also the main way a wrong key is caught: garbage plaintext almost always ends in an invalid padding pattern, so decryptBinary() returns false. It is a sanity check, not authentication — see the security notes below.

The IV is copied per call

mbedtls_aes_crypt_cbc (and the reference) mutate the IV buffer as they chain blocks. If the core passed the instance's stored IV directly, the first call would destroy it. So every operation works on a disposable copy:

uint8_t iv[16];
memcpy(iv, iv_, 16);            // iv_ is the instance's fixed IV
cipher_->encrypt(key_, iv, …);    // scribbles on the copy, not iv_

This is what makes the fixed-IV design work mechanically: iv_ is the same for every call, so the same plaintext encrypts to the same ciphertext. That is convenient and matches a static-IV server protocol, but it is also the security caveat — identical inputs are visibly identical on the wire. The design note on the API page explains when that is acceptable and how to move to per-message IVs later (only the primitive's IV source would change; padding, base64, and the API stay put).

Base64 lives in the core (and why that's safe)

The String API returns ciphertext as base64 so it is safe to log, store in JSON, or send over a text protocol. Base64 is an encoding, not encryption — it hides nothing — so implementing it in-tree carries no security risk, and keeping it portable means the string/transport paths are fully host-tested. The encoder is standard RFC 4648 with = padding; the decoder rejects malformed input (wrong length, illegal characters, misplaced padding) by returning -1 rather than guessing.

The device-only String encrypt()/decrypt() are thin glue: encrypt → encryptBinarybase64Encode; decrypt → base64DecodedecryptBinary. Their logic is covered by the binary and base64 host tests, so wrapping them in #if defined(ARDUINO) costs no coverage.

Device vs. host build

The mbedTLS wrapper sits under src/crypto/, which the native test environment excludes:

[env:native]
build_src_filter = +<*> -<main.cpp> -<backends/> -<crypto/>

So the host build compiles only the portable core and links it against the injected RefAesCbc. On-device, MbedAesCbc.cpp compiles and a file-scope initializer installs the real cipher before setup() runs — no manual wiring:

namespace {
struct MbedAesCbcInstaller {
  MbedAesCbcInstaller() { OXSecurity::setCipher(&cipher); }
  MbedAesCbc cipher;
};
MbedAesCbcInstaller g_installer;  // constructed at static-init time
}

This mirrors how OXFileManager keeps its LittleFS/SdFat backends out of the host build. The cipher pointer is a single shared static, so every OXSecurity instance uses the same vetted primitive while carrying its own key and IV.

What this class does not do

Request lifecycle, end to end

encryptBinary(in, inLen, out, outCap, outLen):

  1. Guard: fail if unconfigured, no cipher installed, or null output.
  2. Compute maxEncryptedLen(inLen); fail if it exceeds outCap.
  3. Copy plaintext into out and append PKCS#7 padding.
  4. Copy the instance IV to a scratch buffer; call the primitive in place.
  5. Set outLen to the padded length and return true.

decryptBinary(...) mirrors it: validate the length is a non-zero multiple of 16, run the primitive on a scratch IV, then validate and strip PKCS#7 — surfacing a wrong key as an invalid-padding failure.

Toward OXSecurityManager

The instance-based shape is the whole point: a later OXSecurityManager can own a local-disk OXSecurity and a server-comms OXSecurity, each with distinct keys and IVs, and expose intent-named wrappers (encryptForServer(), decryptFromDisk()). Because the primitive is a shared static and each instance carries only its own secrets, adding the manager requires no change to OXSecurity itself.

File map

FileRole
include/OXSecurity.hPublic API, constants, IAesCbc interface, base64 declarations.
src/OXSecurity.cppPortable core: PKCS#7, base64, buffer sizing, validation, String glue. No mbedTLS.
include/crypto/MbedAesCbc.h · src/crypto/MbedAesCbc.cppmbedTLS AES-256-CBC primitive + auto-installer (device only).
test/test_oxsecurity/RefAesCbc.hTextbook AES-256-CBC oracle for host tests, pinned to NIST.
test/test_oxsecurity/test_oxsecurity.cppUnity suite: NIST KAT, round trips, padding, tamper, validation, base64, multi-instance.