Skip to content

Omni-Link II protocol

Omni-Link II secure-session handshake Sequence diagram showing four packets exchanged between client and controller to establish an encrypted session: ClientRequestNewSession, ControllerAckNewSession (carrying the SessionID), ClientRequestSecureSession (encrypted), and ControllerAckSecureSession (encrypted). After the handshake the first encrypted v2 message can be sent. CLIENT CONTROLLER 1 ClientRequestNewSession (0x01) no payload · plaintext · seq=1 2 ControllerAckNewSession (0x02) 7 bytes: 00 01 + 5-byte SessionID · plaintext SessionKey = ControllerKey[0:11] || (ControllerKey[11:16] XOR SessionID) 3 ClientRequestSecureSession (0x03) SessionID echoed · AES-128-ECB · per-block whitening 4 ControllerAckSecureSession (0x04) SessionID echoed back · proves controller has the key 5 OmniLink2Message (0x20) e.g. RequestSystemInformation · session is now ONLINE

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.

  1. 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)
  2. Per-block pre-whitening before AES. Before AES.Encrypt is 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)
  3. No separate v2 Login on TCP. clsOL2MsgLogin is defined but never constructed in the decompiled binary on the TCP path. Once ControllerAckSecureSession arrives, PC Access immediately starts issuing real commands. v2 Login (opcode 42) appears to be a serial-only / legacy artifact for TCP usage. The v1 serial path does send clsOLMsgLogin (lines 1137-1162).
  4. 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)
  5. 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)
  6. ControllerAckNewSession protocol-version field is 00 01 literal, not a free-form ushort. PC Access hard-rejects anything else. (lines 1416, 1879)
Section titled “Connect-to-first-command flow (TCP / Omni-Link II v2)”
#SenderPacket typeOuter seqEncrypted?Payload bytes (after 4-byte header)Expected response
0client(TCP SYN to :4369)TCP SYN-ACK
1clientClientRequestNewSession (0x01)1noempty (data length = 0)ControllerAckNewSession
2controllerControllerAckNewSession (0x02)1 (echoes client)no7 bytes: 00 01 + 5-byte SessionIDclient computes SessionKey, sends step 3
3clientClientRequestSecureSession (0x03)2yes (AES with new SessionKey)5 bytes = SessionID, padded to 16, XOR-whitened, AES-encrypted (ciphertext is 16 bytes)ControllerAckSecureSession
4controllerControllerAckSecureSession (0x04)2yes16 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
5clientOmniLink2Message (0x20) wrapping any v2 opcode (e.g., RequestSystemInformation = 22)3yesinner v2 message, padded, whitened, AES’dmatching 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).

Omni-Link II packet and message structure The wire packet has a 4-byte plaintext header (sequence number, packet type, reserved) followed by an optional payload. For OmniLink2Message packets the payload is AES-encrypted bytes that decrypt to an inner Message: start char (0x21), length byte, opcode byte, data bytes, and a two-byte CRC-16/MODBUS. Wire packet 4-byte plaintext header + N-byte payload seq (BE u16) monotonic, skips 0 type 0x20 = v2 msg reserved 0x00 AES-encrypted payload N × 16 bytes (zero-padded, per-block whitened) decrypts to → Inner message (after decrypt + unwhiten) v2 framing: start, length, opcode, data, CRC start 0x21 length u8 of data opcode e.g. 0x16 = SysInfo data payload bytes for this opcode CRC (LE u16) CRC-16/MODBUS Plaintext on the wire AES-encrypted (with per-block whitening)

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)”
Empty-payload control packets Five packet types share an identical four-byte structure with no payload: NoMessage (0x00), ClientRequestNewSession (0x01), ClientSessionTerminated (0x05), ControllerSessionTerminated (0x06), and ControllerCannotStartNewSession (0x07). The wire packet is exactly four bytes total. Empty-payload control packets 4 bytes total on the wire — header only, no payload. seq_hi u8 seq_lo u8 type u8 reserved 0x00 (no payload) SEQ NUMBER · u16 BE PACKET TYPE type = 0x00 NoMessage · 0x01 ClientRequestNewSession · 0x05 ClientSessionTerminated 0x06 ControllerSessionTerminated · 0x07 ControllerCannotStartNewSession

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).

OffsetSizeFieldNotes
0(no payload)clsOmniLinkPacket.Data == null (line 1283/1688).
ControllerAckNewSession (type 0x02) Reply from the controller to a new session request. Carries a 7-byte payload: a hard-coded protocol version (00 01) followed by a 5-byte random SessionID nonce. The SessionID is what gets XOR-mixed with the ControllerKey to derive the AES session key. ControllerAckNewSession (type 0x02) 11 bytes total. Plaintext on the wire. seq_hi u8 seq_lo u8 type 0x02 resv 0x00 proto_hi 0x00 proto_lo 0x01 sid 0 u8 sid 1 u8 sid 2 u8 sid 3 u8 sid 4 u8 HEADER (4 bytes) PROTO VERSION SESSION ID (5-byte nonce) — feeds session key XOR mix PC Access hard-rejects any proto version other than 00 01. The SessionID is freshly random per session.

Payload size 7 bytes (TCP reader hardcodes tcpReadBytes(array, 7) on this type, line 1714).

OffsetSizeFieldNotes
01ProtoVersionHimust be 0x00 (line 1416)
11ProtoVersionLomust be 0x01 (line 1416). Together they encode “Omni-Link II protocol v0001”.
25SessionIDrandom 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)”
ClientRequestSecureSession (0x03) and ControllerAckSecureSession (0x04) Both secure-session packets carry the same payload shape: the 5-byte SessionID echoed back, zero-padded to a 16-byte AES block, encrypted with the freshly-derived session key, and per-block whitened. 20 bytes total on the wire. ClientRequestSecureSession (0x03) · ControllerAckSecureSession (0x04) Same shape both directions. 20 bytes total. Encrypted payload = SessionID echo, zero-padded, AES-encrypted with per-block whitening. plaintext (before AES + whitening): sid 0 sid 1 sid 2 sid 3 sid 4 u8 u8 u8 u8 u8 …zero pad to 16 bytes… SESSION ID ECHO ZERO PADDING on the wire (after AES + whitening): seq_hi seq_lo type 03/04 resv 0x00 16-byte AES-128 ciphertext (one block)

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):

OffsetSizeFieldNotes
05SessionIDecho of the controller’s nonce
511zero padadded by EncryptPacket (PaddingMode.Zeros, line 382-393)

Then EncryptPacket runs (lines 396-401):

  1. For block 0 (the only block): data[0] ^= seq_hi, data[1] ^= seq_lo.
  2. AES.Encrypt(data) using the freshly derived SessionKey — so the controller can only decrypt this if it computed the same key from its own ControllerKey and the SessionID it generated.

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)”
OmniLinkMessage and OmniLink2Message — encrypted vs unencrypted variants Four packet types share this layout. v1 encrypted (0x10), v1 plaintext (0x11), v2 encrypted (0x20), and v2 plaintext (0x21). The payload is one or more 16-byte AES blocks for encrypted variants, or the inner Message bytes laid out directly for unencrypted variants. The inner Message starts with a start byte (0x41 for v1, 0x21 for v2), a length, the opcode, data, and a two-byte CRC. OmniLinkMessage / OmniLink2Message — four variants v1 = 0x10 encrypted · 0x11 plaintext · v2 = 0x20 encrypted · 0x21 plaintext · same shape, different framing. encrypted variants (0x10 / 0x20) — payload is N × 16 bytes of AES ciphertext: seq_hi seq_lo type 10 / 20 resv block 1 16 B block 2 16 B block 3 block N 16 B HEADER (4 B) CIPHERTEXT (per-block whitened) decrypts + unwhitens to → inner Message (after AES + unwhiten, or directly on the wire for 0x11 / 0x21): start 41/21 length u8 opcode u8 data N bytes CRC u16 LE · MODBUS

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):

OffsetSizeFieldNotes
01StartChar0x21
11MessageLength5
21opcode42 (Login)
31Code1digit 1 of PIN, packed as raw byte (NOT ASCII — value is the digit 0..9)
41Code2digit 2
51Code3digit 3
61Code4digit 4
72CRCCRC-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.

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 bytes
for j in range(5):
SessionKey[11 + j] ^= SessionID[j] # last 5 bytes only

Source 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.

For every outbound encrypted packet (OmniLinkMessage 0x10 or OmniLink2Message 0x20):

  1. Build the inner-message bytes: [StartChar=0x41|0x21][MessageLength][...payload...][CRC1][CRC2] (CRC = CRC-16/MODBUS over [MessageLength..end-of-payload], see clsOmniLinkMessage._crcCalculate).
  2. Total size = MessageLength + 4. Zero-pad to next multiple of 16 (lines 378-395).
  3. 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.
  4. AES-128-ECB encrypt the whole padded buffer with SessionKey (line 401). PaddingMode.Zeros is set but at this point the buffer is already a 16-byte multiple, so AES adds no further padding.
  5. 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.

  • 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 inner MessageLength field — Data[0] is StartChar 0x21).
  • Total inner-message size = MessageLength + 4; we already have 16 bytes; need MessageLength + 4 - 16 = MessageLength - 12 more bytes, rounded up to a multiple of 16.
  • Read those extra bytes, append, pass the full ciphertext through DecryptPacket again 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.

  • Client side: pktSequence is set to 1 on TCP/UDP connect (lines 1251, 1619), then pktSequence++ happens inside tcpSend/udpSend immediately before sending (lines 1525, 1987). So the very first ClientRequestNewSession goes out with seq = 2: connect sets pktSequence = 1, then tcpSend increments 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 == pktSequence is the match condition).
  • Unsolicited packets (alarm events, status changes) arrive with seq = 0 and are routed to HUP (HandleUnsolicitedPacketDelegate, lines 1389-1397, 1847-1854).
  • Wraparound: pktSequence is ushort. 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-pca skips 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.
  • WatchdogInterval = 30000 ms (line 19). Set in the clsOmniLinkConnection constructor.
  • 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)
  • 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”.
  • Graceful client teardown (UDP): udpDisconnect sends a ClientSessionTerminated (0x05) packet with the next pktSequence and no payload (lines 1501-1505), sleeps 100 ms, then closes the socket.
  • Graceful client teardown (TCP): tcpDisconnect (lines 1950-1969) does NOT send ClientSessionTerminated. 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 to Offline and reports ControllerSessionTerminated (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).
  1. ClientSessionTerminated payloadudpDisconnect writes 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.
  2. ControllerAckSecureSession plaintext — 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.
  3. 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.
  4. v2 Login on TCPclsOL2MsgLogin is defined but never instantiated. Strongly suggests the v2/TCP path never uses it. The higher-level UI (PCAccess3 namespace) 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 a clsOL2MsgLogin with the user’s PIN as 4 raw bytes and looking for Ack/Nak.
  5. 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.
  6. 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).
  7. ControllerAckNewSession byte 0/1 = 00 01 — called “ProtoVersionHi/Lo” here by analogy with the published Omni-Link spec. The source just hard-checks B[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.
  • Outer packet layout: clsOmniLinkPacket.cs
  • Inner v2 message layout: clsOmniLink2Message.cs, clsOmniLinkMessage.cs
  • AES wrapper: clsAES.cs (just a thin RijndaelManaged shim)
  • Packet types enum: enuOmniLinkPacketType.cs
  • v2 opcodes enum: enuOmniLink2MessageType.cs
  • Where ControllerKey lives in the .pca file: see the file format reference — extracted from Connection.ControllerKey at clsHAC.cs:8044-8056.