Skip to content

The two non-public quirks

The Omni-Link II protocol, as documented in the publicly-available spec, looks like a textbook AES-128-ECB session over TCP: handshake, derive a key, encrypt everything from then on. As implemented by HAI’s PC Access 3.17, it isn’t. There are two quirks in the way the session key is derived and the way payload blocks are encrypted that are not in any third-party Omni-Link writeup we could find. Both are unambiguous in the decompiled C# (clsOmniLinkConnection.cs). Both are load-bearing: if a client skips either, the panel accepts the connection, completes the unencrypted handshake, and then drops the session on the first encrypted message — ControllerSessionTerminated, no diagnostic, no log.

Why these quirks exist (informed speculation)

Section titled “Why these quirks exist (informed speculation)”

Both quirks have the texture of defense by inconvenience. Neither makes the protocol meaningfully harder to attack — anyone with a packet capture and the ControllerKey can reproduce both transformations in a few lines of code. But both add just enough complexity that a casual reverse engineer reading the public spec will write a client that doesn’t work, and won’t have an obvious explanation for why.

It looks like the kind of thing where someone on the original team said “let’s not make it trivial for the obvious clones,” and the implementation has the slight inelegance of cargo-culted-from-one-block-to-all-blocks that suggests it was added by hand rather than designed in. The first quirk may also have been an attempt at session-key freshness — mix a controller-supplied nonce so that two sessions with the same ControllerKey don’t use literally the same AES key. That’s a reasonable goal; a 5-byte XOR is just an unusual way to achieve it.

Whatever the origin, both quirks are stable across the firmware versions PC Access 3.17 supports (the v2-on-TCP path), and both must be implemented exactly to talk to the panel.

Session key derivation — quirk #1 The 16-byte session AES key is built from the 16-byte ControllerKey and the 5-byte SessionID. Bytes 0 through 10 of the ControllerKey are kept verbatim. Bytes 11 through 15 of the ControllerKey are XORed with bytes 0 through 4 of the SessionID. The result is the per-session AES-128 key. ControllerKey (16 bytes) 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 11 bytes kept verbatim → 5 bytes XORed ↓ SessionID (5 bytes) 0 1 2 3 4 SessionKey (16 bytes) CK[0..11) — verbatim CK[11..16) ⊕ SessionID

The ControllerKey is the 16-byte AES-128 key that lives in the panel’s NVRAM and inside the encrypted .pca config file. The naive expectation is that this key is what AES uses for the session. It isn’t.

From clsOmniLinkConnection.cs:1886-1892 (the TCP path):

SessionKey = new byte[16];
ControllerKey.CopyTo(SessionKey, 0);
for (int j = 0; j < 5; j++)
{
SessionKey[11 + j] = (byte)(ControllerKey[11 + j] ^ SessionID[j]);
}
AES = new clsAES(SessionKey);

The first 11 bytes of the session key are the ControllerKey verbatim. The last 5 bytes are the ControllerKey XORed with a 5-byte SessionID nonce that the controller sent in the unencrypted ControllerAckNewSession packet. That’s the entire key derivation. No PBKDF2, no HKDF, no PIN, no salt. Five bytes of XOR.

The same five-byte block appears at :1423-1429 for the UDP path. Identical.

The Python equivalent:

def derive_session_key(controller_key: bytes, session_id: bytes) -> bytes:
assert len(controller_key) == 16
assert len(session_id) == 5
sk = bytearray(controller_key)
for j in range(5):
sk[11 + j] ^= session_id[j]
return bytes(sk)

A naive client that uses ControllerKey directly as the AES key will encrypt ClientRequestSecureSession (the first encrypted packet) with the wrong key. The panel decrypts it to garbage — ECB has no integrity check, so no exception fires; the panel just sees that the SessionID echo doesn’t match what it sent — and drops the session with ControllerSessionTerminated. PC Access surfaces this as InvalidEncryptionKey, which sounds like “your ControllerKey is wrong” but really means “your derived key is wrong, which in practice is always because you didn’t apply the XOR mix.”

Quirk #2 — per-block XOR pre-whitening before AES

Section titled “Quirk #2 — per-block XOR pre-whitening before AES”
Per-block XOR pre-whitening — quirk #2 Before AES-encrypting each 16-byte block of a packet's payload, the first two bytes of every block are XORed with the packet's 16-bit sequence number, high byte first then low byte. The same XOR mask is applied to every block in the same packet. Decryption reverses the operation after AES-decrypt. seq = 0x0042 …the packet's 16-bit sequence number is the XOR mask source. Block 1 first 16 bytes of payload ⊕00 ⊕42 Block 2 next 16 bytes — same mask ⊕00 ⊕42 Block N …same mask, every block ⊕00 ⊕42 ⊕ seq_hi ⊕ seq_lo then AES

This is the headline.

Before AES-encrypting any payload block, the first two bytes of every 16-byte block get XORed with the packet’s 16-bit sequence number. Same XOR mask, every block of the packet. From clsOmniLinkConnection.cs:396-401:

for (num = 0; num < PKT.Data.Length; num += 16)
{
PKT.Data[num] = (byte)(PKT.Data[num] ^ ((PKT.SequenceNumber & 0xFF00) >> 8));
PKT.Data[num + 1] = (byte)(PKT.Data[num + 1] ^ (PKT.SequenceNumber & 0xFF));
}
PKT.Data = AES.Encrypt(PKT.Data);

And the inverse on receive (:413-417):

PKT.Data = AES.Decrypt(PKT.Data);
for (int i = 0; i < PKT.Data.Length; i += 16)
{
PKT.Data[i] = (byte)(PKT.Data[i] ^ ((PKT.SequenceNumber & 0xFF00) >> 8));
PKT.Data[i + 1] = (byte)(PKT.Data[i + 1] ^ (PKT.SequenceNumber & 0xFF));
}

So the on-the-wire encryption is “AES-128-ECB of (payload XOR-prewhitened with the seq number, two bytes per block)”. This is not CBC. It is not CTR. It is an outer transformation applied to the plaintext before AES sees it (and reversed after AES decryption on the wire), independent of AES’s mode.

The Python equivalent:

def whiten(data: bytes, seq: int) -> bytes:
out = bytearray(data)
seq_hi = (seq >> 8) & 0xFF
seq_lo = seq & 0xFF
for i in range(0, len(out), 16):
out[i] ^= seq_hi
out[i + 1] ^= seq_lo
return bytes(out)
def encrypt_payload(payload: bytes, seq: int, session_key: bytes) -> bytes:
# payload is already zero-padded to a 16-byte multiple by the caller.
return aes_ecb_encrypt(whiten(payload, seq), session_key)
def decrypt_payload(ciphertext: bytes, seq: int, session_key: bytes) -> bytes:
return whiten(aes_ecb_decrypt(ciphertext, session_key), seq)

The whiten function is its own inverse — XOR is symmetric — so the same helper works both directions.

Cryptographically this is weak. An attacker with a known-plaintext for one block can recover both bytes of the seq XOR mask by XORing the plaintext against the un-AES’d ciphertext. From there the AES-encrypted bits are unprotected by the whitening. It feels like the original intent might have been nonce-mixing — use the seq as a per-packet salt to defeat ECB’s identical-block-equals-identical-ciphertext property — and the implementation got cargo-culted from one block (where it would have been roughly defensible) to every block of the packet (where it isn’t doing useful work beyond the first one). Doesn’t matter. It’s the protocol. Implement it. Move on.

Section titled “Why public OSS Omni-Link clients miss these”

The two non-trivial public Omni-Link II clients we checked are jomnilinkII (Java) and pyomnilink (Python), plus a handful of writeups on personal blogs. None of them describe either quirk. We can’t be sure from the outside why, but two plausible explanations:

  1. Inherited working code from a pre-quirk firmware era. If an early version of the panel firmware used ControllerKey directly as the session key and didn’t have the XOR pre-whitening, an OSS client written against that firmware would just keep working as long as the panel maintained backward compatibility on the wire — even though new firmware added the quirks for new clients. We don’t have the firmware history to confirm or refute this.
  2. Serial-only / unencrypted paths. Both quirks live in the clsOmniLinkConnection.EncryptPacket / DecryptPacket methods, which are only invoked on packet types OmniLinkMessage (0x10) and OmniLink2Message (0x20). The unencrypted twin packet types (0x11, 0x21) bypass them entirely. A client that only ever talks to the panel over the unencrypted v1 serial path would never need them.

Either way, the practical outcome is that an existing OSS client is not a useful reference for someone trying to write a v2-on-TCP encrypted client from scratch. The decompiled PC Access C# is.

The most direct way to prove our implementation of both quirks is correct is to build a controller-side emulator that round-trips with the client. omni_pca.mock_panel.MockPanel is exactly that: a TCP server that runs the controller half of the handshake, derives the same SessionKey, applies the same per-block XOR pre-whitening, and decodes / encodes real Omni-Link II messages. The library’s e2e test suite connects a real OmniClient to a real MockPanel over a real TCP socket and exchanges real frames. Seventeen of those tests cover the secure-session handshake, encrypted command roundtrips, and the unsolicited push-event stream.

If either quirk were implemented incorrectly on either side, decryption would produce garbage and the connection would drop. The fact that all seventeen tests pass — including ones that subscribe to events and watch them roundtrip cleanly through the encrypted channel — is bidirectional validation that we have both quirks right.

That doesn’t prove they’re right against a real HAI panel. The user’s panel is currently offline (Ethernet module disabled at the panel firmware), and the live-validation lap is on the backlog. But round-tripping with a faithful emulator is meaningful evidence that the spec we extracted from the C# is internally consistent — and that’s the work that the public clients didn’t do.