AES-CCM
AES-CCM: authenticated encryption, as used by Bluetooth, Zigbee, and BTHome's encrypted advertising. It encrypts a message and produces a short tag (a MIC) that proves the message has not been altered and came from someone holding the key.
It uses whichever your firmware has, with nothing to configure: the
firmware's AES.ccmEncrypt and AES.ccmDecrypt when the build has AES_CCM,
and CCM built out of AES.encrypt when it has only AES.
Both kinds are real. Puck.js builds AES alone.
Bangle.js 2 built AES_CCM alone from 2024 until October 2026 —
so it had no AES.encrypt at all, and any module assuming one simply could not
run there — and now builds both.
require("AESCCM").usingNative() tells you which path your board took.
Usage
var ccm = require("AESCCM");
var key = new Uint8Array(16); // 16 bytes
var nonce = new Uint8Array(13); // 7 to 13 bytes, never reused
var message = E.toUint8Array("hello");
var sealed = ccm.encrypt(message, key, nonce, 4);
// -> { data: Uint8Array, mic: Uint8Array(4) }
var back = ccm.decrypt(sealed.data, key, nonce, sealed.mic);
// -> Uint8Array, or null if the MIC does not match
encrypt(plaintext, key, nonce, micLength) |
returns {data, mic}. micLength is even, 4 to 16, and defaults to 4 |
decrypt(ciphertext, key, nonce, mic) |
returns the plaintext, or null if it does not authenticate |
usingNative() |
true if the firmware's own CCM is being used |
decrypt returns null rather than throwing, because a bad MIC is an expected
event — a wrong key, or someone trying it on — and not a mistake by the caller.
Check for it. A plaintext you did not authenticate is not a plaintext.
There is no associated data (AAD): CCM allows it, this does not implement it.
Notes
Never reuse a nonce with the same key. Two messages sealed under one nonce leak the keystream, which is the one mistake that turns this from protection into decoration. Typically the nonce carries a counter that only ever goes up, and the counter is saved across restarts.
On the JS path, sealing one short packet costs about 75 ms on a Puck.js,
measured — and most of that is the JavaScript around the cipher rather than the
cipher itself. The module works in 32-byte pieces, because AES.encrypt
allocates its result as one contiguous block and fails — returning undefined
— when the heap has no run that long, which happens well before memory runs
out. Doing it in two large calls was twice as quick and failed under memory
pressure. On the native path none of this applies.
Counter mode is not used, although it would be the obvious way to make the
keystream: this firmware ignores CTR's iv and always starts from a zero
counter block, so it would be silently, catastrophically wrong. ECB encrypts
each block independently, which is all a keystream needs.
Testing
The module is held to a published set of test vectors, which both paths and two independent implementations agree on: OpenSSL's AES-CCM in the test suite, and a Bangle.js-class board running the firmware's own — 15 vectors, no disagreement.
References
- BTHomeWritable — the module this was written for
- bthome-writable — source, vectors, and the reasoning behind the construction
- RFC 3610 — CCM itself
This page is auto-generated from GitHub. If you see any mistakes or have suggestions, please let us know.