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:
-
Like the rest of the codebase (see OXLog's
injectable sink and OXFileManager's
injectable backends),
OXSecurityshould be unit-testable on a laptop with no hardware. - But the production cipher must be vetted mbedTLS, which is only available in the ESP32 build — and we must not ship a hand-rolled AES.
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 → encryptBinary → base64Encode; decrypt →
base64Decode → decryptBinary. 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
- No integrity/authentication. CBC cannot detect tampering; the padding check is not a MAC. Layer HMAC-SHA256 over the ciphertext, or switch to AES-GCM, if you need it.
- No key management. The class holds the key in RAM and nothing more — no derivation (KDF), no storage, no rotation. Provision keys from secure storage / eFuse and keep them out of source control.
-
Not constant-time on host.
RefAesCbcis a readable reference, not a hardened implementation — it is test-only. Production uses mbedTLS.
Request lifecycle, end to end
encryptBinary(in, inLen, out, outCap, outLen):
- Guard: fail if unconfigured, no cipher installed, or null output.
- Compute
maxEncryptedLen(inLen); fail if it exceedsoutCap. - Copy plaintext into
outand append PKCS#7 padding. - Copy the instance IV to a scratch buffer; call the primitive in place.
- Set
outLento 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
| File | Role |
|---|---|
include/OXSecurity.h | Public API, constants, IAesCbc interface, base64 declarations. |
src/OXSecurity.cpp | Portable core: PKCS#7, base64, buffer sizing, validation, String glue. No mbedTLS. |
include/crypto/MbedAesCbc.h · src/crypto/MbedAesCbc.cpp | mbedTLS AES-256-CBC primitive + auto-installer (device only). |
test/test_oxsecurity/RefAesCbc.h | Textbook AES-256-CBC oracle for host tests, pinned to NIST. |
test/test_oxsecurity/test_oxsecurity.cpp | Unity suite: NIST KAT, round trips, padding, tamper, validation, base64, multi-instance. |