Omni-Link II protocol
TCP/v2 PC Access opens a secure session by exchanging two unencrypted control
packets to derive a per-session AES-128-ECB key from the panel’s 16-byte
ControllerKey XOR-mixed with a 5-byte controller-supplied SessionID, then
everything that follows is AES-ECB-encrypted with a per-block sequence-number
XOR pre-whitening. There is no separate v2 Login step on TCP — possessing
the right ControllerKey is the authentication.
All line citations are into decompiled/project/HAI_Shared/clsOmniLinkConnection.cs
unless otherwise noted.
TL;DR — the load-bearing surprises
Section titled “TL;DR — the load-bearing surprises”- Session key is NOT just the ControllerKey. Bytes
[11..16)(the last 5 bytes) are XORed with the 5-byte SessionID returned by the controller. Bytes[0..11)are the ControllerKey verbatim. (lines 1423-1429 / 1886-1892) - Per-block pre-whitening before AES. Before
AES.Encryptis called on the packet payload, the first two bytes of every 16-byte block are XORed with the packet’s 16-bit sequence number (high byte first). Decryption reverses it. (lines 396-401 / 413-417) - No separate v2 Login on TCP.
clsOL2MsgLoginis defined but never constructed in the decompiled binary on the TCP path. OnceControllerAckSecureSessionarrives, PC Access immediately starts issuing real commands. v2Login(opcode 42) appears to be a serial-only / legacy artifact for TCP usage. The v1 serial path does sendclsOLMsgLogin(lines 1137-1162). - Sequence number is per-direction monotonic, never resets, and the controller echoes the client’s seq on solicited replies. Unsolicited packets arrive with seq = 0. (lines 1796, 1389, 1847)
- PaddingMode.Zeros + per-block reads. TCP framing reads exactly one
16-byte AES block first, decrypts it to learn
MessageLength, then reads enough additional 16-byte blocks to cover the rest. (lines 1731-1759) ControllerAckNewSessionprotocol-version field is00 01literal, not a free-form ushort. PC Access hard-rejects anything else. (lines 1416, 1879)
Connect-to-first-command flow (TCP / Omni-Link II v2)
Section titled “Connect-to-first-command flow (TCP / Omni-Link II v2)”| # | Sender | Packet type | Outer seq | Encrypted? | Payload bytes (after 4-byte header) | Expected response |
|---|---|---|---|---|---|---|
| 0 | client | (TCP SYN to :4369) | — | — | — | TCP SYN-ACK |
| 1 | client | ClientRequestNewSession (0x01) | 1 | no | empty (data length = 0) | ControllerAckNewSession |
| 2 | controller | ControllerAckNewSession (0x02) | 1 (echoes client) | no | 7 bytes: 00 01 + 5-byte SessionID | client computes SessionKey, sends step 3 |
| 3 | client | ClientRequestSecureSession (0x03) | 2 | yes (AES with new SessionKey) | 5 bytes = SessionID, padded to 16, XOR-whitened, AES-encrypted (ciphertext is 16 bytes) | ControllerAckSecureSession |
| 4 | controller | ControllerAckSecureSession (0x04) | 2 | yes | 16 bytes ciphertext (decrypts to 5-byte SessionID + zero pad — the controller proves it knows the key by encrypting the same SessionID back) | client transitions to OnlineSecure |
| 5 | client | OmniLink2Message (0x20) wrapping any v2 opcode (e.g., RequestSystemInformation = 22) | 3 | yes | inner v2 message, padded, whitened, AES’d | matching v2 reply |
Implementation detail (line 1697): the client calls tcpSend() only once
before entering the receive loop — the same loop drains response-handler
callbacks (HRP) which queue the next packet, so step-3 is enqueued inside
the response handler for step-2 and gets sent automatically (lines 1864-1921).
Encryption applies on transmit only when PKT.Data != null && PKT.Data.Length > 1
(line 374). The empty-payload ClientRequestNewSession is therefore sent in
the clear regardless of the OmniLink2Message packet type’s “encrypted”
semantics.
On ControllerSessionTerminated arriving instead of ControllerAckSecureSession,
the client treats it as InvalidEncryptionKey — the controller’s way of
saying “your derived key didn’t decrypt my echo correctly” (lines 1477-1480
UDP / 1808 TCP).
Packet payload byte layouts
Section titled “Packet payload byte layouts”All offsets are into the packet payload, i.e., after the 4-byte outer
header ([seq_hi][seq_lo][type][reserved=0]).
NoMessage (type 0x00) and ClientRequestNewSession (type 0x01)
Section titled “NoMessage (type 0x00) and ClientRequestNewSession (type 0x01)”Both have empty payloads. NoMessage is the protocol’s keepalive — sent
when the client wants to confirm the TCP connection is alive without doing
anything. ClientRequestNewSession is step 1 of the handshake. Wire = 4
bytes total either way (just the header).
| Offset | Size | Field | Notes |
|---|---|---|---|
| — | 0 | (no payload) | clsOmniLinkPacket.Data == null (line 1283/1688). |
ControllerAckNewSession (type 0x02)
Section titled “ControllerAckNewSession (type 0x02)”Payload size 7 bytes (TCP reader hardcodes tcpReadBytes(array, 7) on this
type, line 1714).
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 1 | ProtoVersionHi | must be 0x00 (line 1416) |
| 1 | 1 | ProtoVersionLo | must be 0x01 (line 1416). Together they encode “Omni-Link II protocol v0001”. |
| 2 | 5 | SessionID | random nonce. Stored into SessionID[0..4] (lines 1418-1422). |
Total wire packet: 4-byte header + 7-byte payload = 11 bytes.
ClientRequestSecureSession (type 0x03) and ControllerAckSecureSession (type 0x04)
Section titled “ClientRequestSecureSession (type 0x03) and ControllerAckSecureSession (type 0x04)”Both directions carry the same 5-byte SessionID echo, zero-padded to a 16-byte AES block, encrypted, and per-block whitened. The client builds step 3 with the SessionID it received in step 2; the controller replies in step 4 with the same SessionID re-encrypted. The implicit “did the AES round-trip succeed?” is the only proof both sides have the same key — ECB provides no integrity check, so the wrong key produces 16 bytes of garbage that the receiver will dutifully accept (see notes below the next table).
ClientRequestSecureSession plaintext layout
Section titled “ClientRequestSecureSession plaintext layout”Payload before encryption is 5 bytes; on the wire it is 16 bytes (one AES block).
Plaintext layout (lines 1430-1438):
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 5 | SessionID | echo of the controller’s nonce |
| 5 | 11 | zero pad | added by EncryptPacket (PaddingMode.Zeros, line 382-393) |
Then EncryptPacket runs (lines 396-401):
- For block 0 (the only block):
data[0] ^= seq_hi,data[1] ^= seq_lo. AES.Encrypt(data)using the freshly derived SessionKey — so the controller can only decrypt this if it computed the same key from its ownControllerKeyand the SessionID it generated.
ControllerAckSecureSession (type 0x04)
Section titled “ControllerAckSecureSession (type 0x04)”Payload size 16 bytes on the wire (TCP reader hardcodes
tcpReadBytes(array, 16), line 1722-1729).
After DecryptPacket (un-AES + un-XOR-whitening), plaintext is symmetric to
step 3: 5 bytes of SessionID + 11 zero bytes. The client doesn’t actually
re-validate the contents — it just trusts that successful AES decryption (no
exception) means key match (lines 1471-1475 / 1933-1937). clsAES.Decrypt
returns null on exception (line 53 of clsAES.cs); DecryptPacket returns
false then; the response handler still treats it as an unrecognized reply
and disconnects.
ECB has no integrity check. AES decryption with the wrong key returns 16
bytes of garbage (no exception), so the only thing that protects the client
from accepting a wrong-key ack is the controller pre-validating step 3 and
sending ControllerSessionTerminated instead.
ClientSessionTerminated (type 0x05), ControllerSessionTerminated (type 0x06), ControllerCannotStartNewSession (type 0x07)
Section titled “ClientSessionTerminated (type 0x05), ControllerSessionTerminated (type 0x06), ControllerCannotStartNewSession (type 0x07)”All three share the empty-payload layout. ClientSessionTerminated is sent
by OmniConnection.close() for a graceful shutdown. ControllerSessionTerminated
is what the panel sends on a wrong key, a session timeout, or when it kicks
an idle client. ControllerCannotStartNewSession is the panel saying “I’m
already talking to someone; the protocol is single-client” — it arrives in
response to a ClientRequestNewSession when an existing session is open.
The four-byte wire form for all three is just [seq_hi seq_lo TYPE 0x00].
OmniLinkMessage (type 0x10), OmniLinkUnencryptedMessage (type 0x11), OmniLink2Message (type 0x20), OmniLink2UnencryptedMessage (type 0x21)
Section titled “OmniLinkMessage (type 0x10), OmniLinkUnencryptedMessage (type 0x11), OmniLink2Message (type 0x20), OmniLink2UnencryptedMessage (type 0x21)”These four packet types are the actual conversation — every command,
status query, and unsolicited push event flows through one of them. The
shape is the same; the type byte tells you whether the payload is
encrypted (0x10 / 0x20) or laid bare on the wire (0x11 / 0x21),
and which protocol version (v1 for 0x1x, v2 for 0x2x).
The encrypted variants on TCP/v2 are what PC Access actually uses
day-to-day. The unencrypted variants exist for the serial path and for
diagnostics. PC Access never sends OmniLink2UnencryptedMessage over
TCP — the panel would accept it but no production deployment uses it.
The inner Message format is documented at the top of this page (start
byte, length, opcode, data, CRC-16/MODBUS). The opcode tables for v1 and
v2 live in omni_pca.opcodes
— too long to reproduce here.
v2 Login (inner opcode 42) — defined but unused on TCP
Section titled “v2 Login (inner opcode 42) — defined but unused on TCP”If the protocol ever calls for it, the layout is (clsOL2MsgLogin.cs):
| Offset | Size | Field | Notes |
|---|---|---|---|
| 0 | 1 | StartChar | 0x21 |
| 1 | 1 | MessageLength | 5 |
| 2 | 1 | opcode | 42 (Login) |
| 3 | 1 | Code1 | digit 1 of PIN, packed as raw byte (NOT ASCII — value is the digit 0..9) |
| 4 | 1 | Code2 | digit 2 |
| 5 | 1 | Code3 | digit 3 |
| 6 | 1 | Code4 | digit 4 |
| 7 | 2 | CRC | CRC-16/MODBUS over bytes 1..6 |
Compare to v1 Login (clsOLMsgLogin.cs) — identical layout but with
MessageType = enuOmniLinkMessageType.Login (=32 in the v1 enum). The v1
version is sent by serialHandleLoginTest after a successful serial CTS
handshake (lines 1137-1160) using either SerialCode (typed-in user PIN)
or 1,1,1,1 if no code is set.
Response opcode for Login would be Ack=1 on success or Nak=2 on bad PIN
(clsOL2MsgAcknowledge.cs, clsOL2MsgNegativeAcknowledge.cs) — payload is
just [0x21][0x01][0x01 or 0x02][CRC1][CRC2]. But again, the TCP path
never sends it.
Session key derivation
Section titled “Session key derivation”The single most important paragraph in this document.
SessionKey[16] = ControllerKey[0..11) || (ControllerKey[11..16) XOR SessionID[0..5))Pseudocode:
SessionKey = bytearray(ControllerKey) # copy 16 bytesfor j in range(5): SessionKey[11 + j] ^= SessionID[j] # last 5 bytes onlySource proof (TCP, clsOmniLinkConnection.cs:1886-1892):
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);Identical block at clsOmniLinkConnection.cs:1423-1429 for the UDP path. The
session key is also used as the AES IV (irrelevant in ECB), via clsAES’s
constructor (line 21 of clsAES.cs).
The ControllerKey is the 16-byte panel-side AES key that lives in the panel’s
NVRAM and inside the encrypted .pca config file (see the file format
reference — extracted from Connection.ControllerKey
at clsHAC.cs:8044-8056).
No PIN, no installer code, no salt, no KDF. Pure XOR-mixing — anyone with a packet capture and the ControllerKey can compute the SessionKey trivially.
Steady-state encryption
Section titled “Steady-state encryption”For every outbound encrypted packet (OmniLinkMessage 0x10 or
OmniLink2Message 0x20):
- Build the inner-message bytes:
[StartChar=0x41|0x21][MessageLength][...payload...][CRC1][CRC2](CRC = CRC-16/MODBUS over[MessageLength..end-of-payload], seeclsOmniLinkMessage._crcCalculate). - Total size =
MessageLength + 4. Zero-pad to next multiple of 16 (lines 378-395). - For each 16-byte block, XOR the first two bytes with the outer
packet’s sequence number:
block[0] ^= seq_hi; block[1] ^= seq_lo;(lines 396-400). Same XOR is applied to every block, not just the first. - AES-128-ECB encrypt the whole padded buffer with
SessionKey(line 401).PaddingMode.Zerosis set but at this point the buffer is already a 16-byte multiple, so AES adds no further padding. - Frame as
[seq_hi][seq_lo][0x20][0x00] + ciphertext.
Inverse on receive: AES-decrypt → XOR-unwhiten the first two bytes of every block with the outer seq → consume.
Receive framing on TCP (lines 1731-1759)
Section titled “Receive framing on TCP (lines 1731-1759)”- Read 4-byte header.
- Read exactly one 16-byte block (
tcpReadBytes(array, 16)). - Build a temp packet with that one block, decrypt it.
- Look at decrypted
Data[1](= the innerMessageLengthfield —Data[0]is StartChar0x21). - Total inner-message size =
MessageLength + 4; we already have 16 bytes; needMessageLength + 4 - 16 = MessageLength - 12more bytes, rounded up to a multiple of 16. - Read those extra bytes, append, pass the full ciphertext through
DecryptPacketagain at the higher level.
Per-message encryption is opt-out via clsOmniLinkMessageQueueItem.Encrypt
(line 495). When Encrypt = false, the packet type is downgraded to
OmniLink2UnencryptedMessage (0x21) and EncryptPacket is bypassed (lines
2001-2008). HAI_Shared never sets Encrypt = false once OnlineSecure —
the field exists for the Login-on-serial path.
Sequence numbers, keepalive, teardown
Section titled “Sequence numbers, keepalive, teardown”Sequence numbers
Section titled “Sequence numbers”- Client side:
pktSequenceis set to 1 on TCP/UDP connect (lines 1251, 1619), thenpktSequence++happens insidetcpSend/udpSendimmediately before sending (lines 1525, 1987). So the very firstClientRequestNewSessiongoes out with seq = 2: connect setspktSequence = 1, thentcpSendincrements to 2 and sends. The original “1” is the post-init value, the wire value is 2. Subsequent client packets are 3, 4, 5, … - The controller echoes the client’s seq on solicited replies (line 1796:
clsOmniLinkPacket2.SequenceNumber == pktSequenceis the match condition). - Unsolicited packets (alarm events, status changes) arrive with
seq = 0and are routed toHUP(HandleUnsolicitedPacketDelegate, lines 1389-1397, 1847-1854). - Wraparound:
pktSequenceisushort. After 65535 it overflows to 0 in C#. The 0 value collides with the “unsolicited” semantics — the source has no special-case wraparound code.omni-pcaskips 0 on wrap. - Encrypted whitening is keyed off the seq, so a Python client must keep the client and controller views of seq in lock-step.
Keepalive
Section titled “Keepalive”WatchdogInterval = 30000ms (line 19). Set in theclsOmniLinkConnectionconstructor.WatchdogTimeout(lines 334-351) fires when 30s elapses with no packet activity AND the message queue is empty:- Protocol v1: sends
clsOLMsgAcknowledge - Protocol v2: sends
clsOL2MsgAcknowledge(opcode 1,Ack)
- Protocol v1: sends
- The watchdog is “armed” by
Watchdog.Change(WatchdogInterval, -1)everywhere a packet is sent; each send resets the 30s clock. - So the keepalive cadence is ”30s of silence → emit a 1-byte Ack message”.
Teardown
Section titled “Teardown”- Graceful client teardown (UDP):
udpDisconnectsends aClientSessionTerminated(0x05) packet with the nextpktSequenceand no payload (lines 1501-1505), sleeps 100 ms, then closes the socket. - Graceful client teardown (TCP):
tcpDisconnect(lines 1950-1969) does NOT sendClientSessionTerminated. It just closes the socket. The controller is expected to notice TCP RST/FIN and clean up its session state on its own. A Python client should probably send the terminator anyway for cleanliness. - Controller-initiated teardown:
ControllerSessionTerminated(0x06). Client transitions toOfflineand reportsControllerSessionTerminated(line 1804) or, if it arrived before secure-session was up,InvalidEncryptionKey(lines 1480, 1808). ControllerCannotStartNewSession(0x07): sent by panel if it’s at max sessions, etc. Client gives up immediately (lines 1449-1452).
Open questions
Section titled “Open questions”ClientSessionTerminatedpayload —udpDisconnectwrites a 4-byte header with empty Data. Is the controller fine with that, or does it expect the SessionID echoed back? The source unambiguously sends nothing, so we trust it, but worth verifying on a live panel.ControllerAckSecureSessionplaintext — asserted to be “5 bytes SessionID + 11 zeros” based on the symmetric design, but the client never inspects the plaintext — it only checks that decryption didn’t throw. The actual bytes the panel returns could be anything (e.g., all-zero, or a different challenge). Need a packet capture to confirm.- Session ID uniqueness — the source treats SessionID as a 5-byte opaque blob; nothing constrains it to be random. If the panel issues a predictable / time-based / counter SessionID, the per-session key collapses to just the ControllerKey for any given ID. Not a flaw to fix in our client, but a fingerprinting opportunity.
- v2 Login on TCP —
clsOL2MsgLoginis defined but never instantiated. Strongly suggests the v2/TCP path never uses it. The higher-level UI (PCAccess3namespace) might construct it via reflection or string-based dispatch. If a real panel rejects post-handshake commands without a Login, the next thing to try is sending aclsOL2MsgLoginwith the user’s PIN as 4 raw bytes and looking forAck/Nak. - Sequence number wraparound — untested. The source has no special-case logic; presumably PC Access just never runs that long. A long-running daemon should at least log when it’s about to wrap, or force a session restart at seq ≈ 65000.
- Per-block XOR-whitening: every block, not just the first. The source unambiguously XORs the same two bytes of the seq into every block’s first two bytes (line 396-400 loop). All blocks within one packet get identical whitening. Confirmed by re-reading three times — it does feel weak (an attacker who has known-plaintext for one block can recover the seq XOR mask, and from there the AES key bit is unprotected).
ControllerAckNewSessionbyte 0/1 =00 01— called “ProtoVersionHi/Lo” here by analogy with the published Omni-Link spec. The source just hard-checksB[4]==0 && B[5]==1. Could equally be a flag word or a “new-session-OK” status code. Doesn’t change client behaviour, but worth noting.
Cross-references
Section titled “Cross-references”- Outer packet layout:
clsOmniLinkPacket.cs - Inner v2 message layout:
clsOmniLink2Message.cs,clsOmniLinkMessage.cs - AES wrapper:
clsAES.cs(just a thinRijndaelManagedshim) - Packet types enum:
enuOmniLinkPacketType.cs - v2 opcodes enum:
enuOmniLink2MessageType.cs - Where
ControllerKeylives in the.pcafile: see the file format reference — extracted fromConnection.ControllerKeyatclsHAC.cs:8044-8056.