A Sawtooth Where the Noise Was
My cousin has lent me an EMOTIV EPOC, a 14 channel EEG headset from the era when consumer brain-computer interfaces were going to be the next big thing. It came with its original proprietary 2.4 GHz USB receiver. The modern path to its data runs through EMOTIV's Cortex API, which wants an application id, a secret, an account, and a paid licence before it will hand over raw EEG.
The interesting question is what I can get out of the hardware with nothing installed. Not a plotting demo. The first USB byte, then the framing, then the encryption, then the samples, in that order, with each step proven on this hardware before the next one starts.
Borrowed kit sets the rules for the whole project. Everything here is strictly
read-only: no output reports, no feature writes, no configuration changes, no
firmware, no touching the pairing. Descriptor reads and GET_FEATURE reads and
nothing else. It goes back to him working exactly as it arrived.
The code, the protocol notes and the lab notes are at github.com/gotnull/brain-drain. The raw captures stay off it, because their metadata carries the dongle serial.
This post covers the first four. The samples are still ahead of me, and the reason why is the most useful thing I learned.
Finding the receiver
system_profiler SPUSBDataType returns nothing on macOS 26.6. Empty output,
exit status 0, both plain and with -detailLevel full. That cost me a few
minutes of assuming the dongle was dead before I switched to ioreg, which
works fine and became the tool for the rest of the project.
The receiver enumerates as vendor 0x21a1, product 0x0001, strings
Emotiv Systems Inc. and EPOC BCI, plus a USB serial I am keeping to myself,
since it is both a borrowed device's identifier and the material the encryption
key is built from. Its shape is the documented one: SN plus YYYYMMDD plus six
characters, which dates the dongle to 2013 and matches the classic EPOC
production era.
It is a composite device with two HID interfaces. Interface 0 sits on vendor
usage page 0xf0ff with a three byte input report. Interface 1 sits on usage
page 0xffff with a 32 byte input report and a six byte feature report. Neither
interface declares an output report, so there is no way to send this thing a
command short of a feature write or a raw control transfer.
The report descriptor for interface 1 is 28 bytes and says exactly what you want to hear:
06 ff ff Usage Page (vendor defined 0xFFFF)
09 02 Usage (0x02)
a1 00 Collection (Physical)
09 03 Usage (0x03)
75 08 Report Size (8 bits)
95 20 Report Count (32) -> 32 byte input report, no report ID
15 81 Logical Minimum (-127)
25 7f Logical Maximum (127)
81 02 Input (Data, Var, Abs)
09 04 Usage (0x04)
75 08 Report Size (8 bits)
95 06 Report Count (6) -> 6 byte feature report
b1 02 Feature (Data, Var, Abs)
c0 End Collection
Both interfaces report UsbExclusiveOwner = AppleUserUSBHostHIDDevice. macOS
binds its own driver to any HID class interface and gives you no supported way
to detach it, which rules out libusb on this platform. Several of the community
projects for this hardware are Linux-first and call detach_kernel_driver;
those cannot work here. HIDAPI is the route, and it opened the device with no
error and no permission prompt.
That last part surprised me until I tested it properly. macOS refuses to open
HID devices in the generic desktop usage page without Input Monitoring
permission. Trying to open the mouse and the keyboard failed with
OSError: open failed, while every vendor usage page device on the machine
opened immediately. The EPOC receiver lives on 0xffff, so it is exempt.
Then nothing happened for hours
The device opened. It produced zero input reports. On both interfaces. For twelve seconds, then twenty, then five continuous minutes.
I checked the obvious things. Nothing else held the device: no
IOHIDLibUserClient was attached to it, and the only HID clients on the machine
belonged to WindowServer for the keyboard and mouse. No EMOTIV software was
running. An earlier Chrome handle at the device level had gone away and had
never blocked anything.
The one channel that did answer was the feature report. GET_FEATURE is a
control read, it cannot change device state, and interface 1 returned six real
bytes:
a0 ff 1f ff 00 00
I polled that once a second for five minutes. It never changed, not once, which gave me a solid baseline and proved the receiver was awake and talking the entire time it was giving me nothing.
So the fault was upstream of USB. My leading theory was the battery. This is 2013 hardware with a lithium-polymer cell that had been in storage for years, and a deeply discharged cell that appears to power on while the radio never comes up is a well-known way for old kit to waste your afternoon. I was wrong, and I was wrong in a way worth recording: I had built a confident story around the most plausible failure and had no evidence for it beyond plausibility.
The actual cause was the orange switch on the headset. It has a power symbol and a USB symbol. With the charging cable unplugged and the switch on the USB symbol, the headset LED went solid blue, the dongle LED went from flashing green to solid, and packets arrived immediately.
The feature report moved too:
a0 ff 1f ff 00 00 no headset linked
21 ff 1f ff 1e 00 headset linked and streaming
Byte 0 changed and byte 4 went from 00 to 1e. That is a cheap link status
probe I now use before every capture, and I would not have found it if the link
had come up first go.
128.03 packets a second, and all of them noise
Two thousand packets, every one of them exactly 32 bytes, at 128.03 reports per second. Mean interval 7.8134 ms, standard deviation 0.556 ms. One packet per sample gives a 128 Hz sample rate, which matches every published figure for the classic EPOC.
Then I looked at the bytes. All 32 positions took all 256 values, every mean sat near 128, no position was constant and no position behaved like a counter. That is what encryption looks like from the outside, and it confirmed the payload was encrypted from my own capture before I went anywhere near a key.
The top half of this figure is byte 0 of 300 consecutive packets as they arrive:

Not trusting the documentation
The community work on this hardware is a decade old and mostly written against a
different dongle, vendor 0x1234 product 0xED02. Mine is 0x21a1. Three
projects document the AES key derivation: Emokit,
python-emotiv and
CyKit. All three agree the key is AES-128
built from the last four characters of the dongle serial. They disagree about
almost everything after that.
The trap is the naming. Emokit's code has a crypto_key(serial, is_research)
with two byte layouts. python-emotiv has two layouts labelled "consumer" and
"research". python-emotiv's "research" is byte for byte identical to Emokit's
is_research=False. python-emotiv's "consumer" matches what Emokit's own
documentation calls consumer, which is not what Emokit's code calls
consumer. CyKit sides with Emokit's code. Anyone copying a layout by its label
has a coin flip in the middle of their pipeline.
Emokit ships a brute-forcer, which tells you how well the labels have served people historically.
So I generated all five documented layouts and let the packets decide. Each one is built from the last four characters of the serial:
def candidate_keys(sn):
s1, s2, s3, s4 = (ord(sn[-1]), ord(sn[-2]), ord(sn[-3]), ord(sn[-4]))
H, T, B, P = 0x48, 0x54, 0x42, 0x50
D, X = 0x44, 0x58
return {
"emokit_consumer (is_research=False)":
[s1, 0x00, s2, T, s3, 0x10, s4, B, s1, 0x00, s2, H, s3, 0x00, s4, P],
"emokit_research (is_research=True)":
[s1, 0x00, s2, H, s1, 0x00, s2, T, s3, 0x10, s4, B, s3, 0x00, s4, P],
"pyemotiv_consumer":
[s1, 0x00, s2, H, s3, 0x00, s4, T, s1, 0x10, s2, B, s3, 0x00, s4, P],
"emokit_new_crypto_key":
[s1, s2, s2, s3, s3, s3, s2, s4, s1, s4, s2, s2, s4, s4, s2, s1],
"emokit_epoc_plus":
[s1, 0x00, s2, 0x15, s3, 0x00, s4, 0x0C, s3, 0x00, s2, D, s1, 0x00, s2, X],
}
The scoring function is where the real work is. Byte 0 of a correctly decrypted packet is supposed to be a counter advancing by one, modulo 128, with occasional battery packets where the high bit is set. Random bytes will not do that, so the measure is simply how often consecutive packets step by exactly one:
def counter_score(decoded):
good = total = 0
for a, b in zip(decoded, decoded[1:]):
c0, c1 = a[0], b[0]
if c0 >= 128 or c1 >= 128:
continue # battery packet, skip the pair
total += 1
if (c0 + 1) % 128 == c1:
good += 1
return good / total if total else 0.0
My first version of that function credited any packet whose byte 0 had its high bit set, on the grounds that a battery packet legitimately follows any counter value. That inflated the score for undecrypted data to 50.59% and made the whole test look mushy. Skipping those pairs instead of rewarding them dropped the baseline to 0.79%. The winner scored 100% under both versions, so the bug never changed the answer, and it did hide how sharp the test actually is. A scoring function that flatters random input will eventually flatter a wrong result too.
Decryption is two independent AES-128-ECB blocks per packet. No IV, no chaining, which is a weakness in the product and a convenience here.

One hundred percent against 2.36% for the runner up. The winning key is sixteen bytes I am not printing, for the same reason as the serial: it decrypts one specific headset that belongs to someone else, and it is reproducible from that headset's own serial by anyone holding the hardware.
It is the layout Emokit's code calls consumer, which is the same bytes python-emotiv calls research. The label was never going to tell me. The packets did.
Two things corroborate the result independently of the counter test. Battery packets appear 8 times in 1000, which is one per 128 packet cycle, exactly the cadence you would expect for a reading substituted once per counter wrap. Every one of them carries raw value 246, which maps to roughly 97% charge on Emokit's empirical lookup table. A wrong key producing a flawless counter and a correctly cadenced constant battery byte is not a thing that happens.
The bottom half of the first figure is that same byte 0 after decryption. Three clean ramps, the battery packets circled at the wrap points.
What I have not solved
The channel mapping. Emokit publishes a bit table that gathers each 14 bit sample from scattered, non-contiguous bit positions, and applied to my packets it produces 0 of 14 channels that behave like EEG. I then scanned every contiguous 12, 14 and 16 bit field at every bit offset in the packet, scoring each by lag-1 autocorrelation, on the reasoning that the EPOC's analogue path is band-limited to about 43 Hz and sampled at 128 Hz, so consecutive samples of a real channel have to be strongly correlated. The only field that scored above 0.9 was byte 0, which is the counter's sawtooth.
Here is the part I want to be careful about. That result does not prove the bit table is wrong for my hardware, and reporting it as though it did would be overclaiming. The capture was taken with the headset switched on and sitting on the desk with dry electrodes. The per-channel statistics show values railing between about 30 and 16350, which is the signature of floating inputs. With no scalp contact there is no band-limited signal for an autocorrelation test to find, so right now the test cannot tell a wrong bit table from an absent signal.
One field is worth remembering: 16 bits at offset 163 sits at mean 1185 with a standard deviation of 26.6 and a range of 1099 to 1267, while every other candidate swings across thousands. Narrow and stable is what a connected channel looks like. That is a hypothesis with a plan attached, not a finding.
The decrypted payload is plainly structured, whatever the mapping turns out to be. Bytes 14, 15, 16, 22 and 29 hold steady across packets while the rest move:
c=53 35 6f be 00 67 26 9d f4 82 e2 2c d9 3e 80 00 00 02 21 b7 e0 20 91 7c e6 ...
c=54 36 6f d6 00 67 26 9d f3 82 f2 2c a9 3f 40 00 00 02 22 17 e0 20 93 7c be ...
c=55 37 6f d6 00 57 26 dd f4 82 da 2c c9 3f 80 00 00 02 22 67 e1 e0 96 7c a2 ...
Where this stands
The receiver is identified, the stream is captured at a measured 128.03 Hz, the framing is understood, and the payload is decrypting with a key confirmed against real packets rather than inherited from a README. The battery reads 97%, which is a pleasant surprise for a cell that spent a decade in a drawer and which I had already written off.
Next is a capture with the felt pads wetted in saline and the headset actually on my head. That turns the autocorrelation test from inconclusive into decisive: real channels will jump well above 0.9 and the correct layout will separate itself from the noise the same way the key did. If Emokit's table then fits, the current negative was contact all along. If it still does not fit, I have a genuine difference between this dongle revision and the published work, and the scan already knows where to look.
After that comes electrode identification by touch, one at a time, because a channel order copied from a table is a guess wearing a lab coat. Then eyes open against eyes closed on the occipital electrodes, which is the smallest experiment that proves the whole chain is measuring a brain rather than a building's mains hum.
The useful lesson from today was not cryptographic. Two of the three real obstacles were a switch position and a scoring function that was too generous to random data. Neither was in the protocol, and both were the kind of thing that looks obvious in hindsight and costs an afternoon in practice.