.pca and PCA01.CFG file format
The .pca and PCA01.CFG files written by HAI’s PC Access are not AES.
Despite the existence of clsAES in the same binary, both file formats use a
Borland-Pascal-style linear-congruential generator (LCG) keystream XORed
byte-by-byte with the plaintext. The cipher is decades old and lives on for
backward compatibility with files emitted by earlier Delphi versions of the
same product line.
This page covers the cipher, the three keys, and the byte-level layout up to
the Connection block — which is what you need to extract the panel’s
network address and AES ControllerKey for the secure
session.
The XOR-LCG cipher
Section titled “The XOR-LCG cipher”clsPcaCryptFileStream implements the file format for both Our House.pca
and PCA01.CFG. The keystream comes out of a Borland Delphi / Turbo Pascal
Random() LCG:
private byte oldRandom(byte max) { RandomSeed = RandomSeed * 134775813 + 1; return (byte)((RandomSeed >> 16) % max);}// per byte: ciphertext = plaintext ^ oldRandom(255) // mod 255, not 256- Multiplier
134775813=0x08088405— the Borland PascalRandom()constant. Someone wrote this thing in Delphi originally, ported it to C#, and kept the exact same PRNG so old.pcafiles still decrypt. % 255, not% 256. The keystream byte is in[0..254], never0xFF. Doesn’t lose information (output distribution is just shifted), but worth noting if you’re tracking down off-by-one weirdness.- Integrity check: rolling CRC-32 over plaintext, stored separately
(
clsCRC32). The cipher itself has no MAC.
A minimal Python decryptor:
def keystream(seed: int): while True: seed = (seed * 134775813 + 1) & 0xFFFFFFFF yield (seed >> 16) % 255
def decrypt(ciphertext: bytes, key: int) -> bytes: ks = keystream(key) return bytes(b ^ next(ks) for b in ciphertext)The three keys
Section titled “The three keys”Two are hardcoded; the third is per-installation and lives inside PCA01.CFG
after first-stage decryption.
// clsPcaCfgprivate readonly uint keyPC01 = 338847091u; // 0x142A3D33 for PCA01.CFGpublic readonly uint keyExport = 391549495u; // 0x17579817 for exported .pca filesThe third path: SetSecurityStamp(string S) derives a per-installation key
from a stamp string:
uint num = 305419896u; // 0x12345678 — developer Easter egg as init valueforeach (char c in S) num = ((num ^ c) << 7) ^ c;Key = num;0x12345678 as an initialization constant is a giveaway someone was bored
at the keyboard — it’s the kind of thing you grep binaries for.
Which key to try
Section titled “Which key to try”| File | Cipher key | Where it comes from |
|---|---|---|
PCA01.CFG (app settings) | 0x142A3D33 (keyPC01) | hardcoded in clsPcaCfg |
*.pca exported via PC Access “Export” | 0x17579817 (keyExport) | hardcoded in clsPcaCfg |
*.pca written in-place by PC Access | per-install Key | inside the decrypted PCA01.CFG |
clsHAC.ReadFromFile confirms the rule (line 8003):
clsPcaCryptFileStream2 = isImportFile ? new clsPcaCryptFileStream(_FileName, FileMode.Open, CFG.keyExport) : new clsPcaCryptFileStream(_FileName, FileMode.Open, CFG.Key);How to know you got the right key
Section titled “How to know you got the right key”Statistical heuristics (entropy, printable-character ratio) are bad here because random noise has higher printable-byte ratio than a real binary plaintext padded with zeros and length-prefixed strings. Use the structural magic instead:
def score(plaintext: bytes) -> int: n = plaintext[0] if not (1 <= n <= 64): return 0 tag = plaintext[1:1+n] if all(32 <= b < 127 for b in tag): return 100 + n return 0The first byte is a String8 length prefix; the next n bytes should be the
ASCII version tag like CFG05 or PCA03. If it parses cleanly, the key is
right.
PCA01.CFG plaintext schema
Section titled “PCA01.CFG plaintext schema”After XOR-decrypting with keyPC01:
String8 version_tag ; e.g. "CFG05" — first byte is length=5, then 5 ASCII charsString8 InitCmd1[String8 InitCmd2, InitCmd3 if v >= 5]String8 LocalCmd, OnlineCmd, AnswerCmd, HangupCmdUInt16 ModemPortNumber, ModemIRQ[UInt16 ModemBaud if v < 5]UInt32 Key ; <-- the per-installation .pca encryption keyString8 Password (10 max)UInt16 PrinterPortUInt16 SerialPortNumber, SerialBaudCodeReservedBytes (up to 2048)If a .pca file was exported on the same machine that wrote the PCA01.CFG,
its key is also sitting inside PCA01.CFG.Key. So the workflow is: decrypt
PCA01.CFG with the hardcoded key, lift Key out, then use that to decrypt
the .pca.
All multi-byte ints in this stream are little-endian
(_WriteByte(I & 0xFF) first). Strings are length-prefixed: string8 =
u8 length + N data bytes; string16 = u16 length + N data bytes.
.pca file format (PCA03)
Section titled “.pca file format (PCA03)”After XOR-decrypting with the per-install key, the file starts with a fixed 2191-byte header, then a model-dependent body.
Header (2191 bytes)
Section titled “Header (2191 bytes)”clsHAC.ReadFileHeader:
| Offset | Type | Field | Notes |
|---|---|---|---|
| 0 | String8 | version_tag | PCA03 (file format v3) |
| 6 | String8(30) | AccountName | fixed-len 30 (1 len + 30 data) |
| 37 | String16(120) | AccountAddress | fixed-len 120 (2 len + 120 data) |
| 159 | String8(20) | AccountPhone | |
| 180 | String8(4) | AccountCode | short alarm-account ID |
| 185 | String16(2000) | AccountRemarks | up to 2000 bytes |
| 2187 | byte | Model | enuModel — 0x10 = OMNI_PRO_II |
| 2188 | byte | MajorVersion | firmware major |
| 2189 | byte | MinorVersion | firmware minor |
| 2190 | sbyte | Revision | firmware revision (negative = beta) |
A useful cross-check: clsHAC.cs:7943 has if (num == 2191) { /* header read OK */ }.
If your byte counter doesn’t equal 2191 after parsing the header, you parsed
it wrong.
ReadString8(out S, byte L) always consumes 1 + L bytes regardless of the
declared string length. The strings are fixed-width slots with a length
prefix, not variable-length records.
Body layout (per clsHAC.ReadFromFile, file v3)
Section titled “Body layout (per clsHAC.ReadFromFile, file v3)”After the 2191-byte header, the body is:
ByteArray SetupData.data (3840 bytes for OMNI_PRO_II)bool slRequireCodeForSecuritybool slPasswordOnRestoreUInt16 _discardedUInt16 EventLog.CountUInt32 _discarded
ZoneNames, UnitNames, ButtonNames, CodeNames, ThermostatNames, AreaNames, MessageNames
ZoneVoices, UnitVoices, ButtonVoices, CodeVoices, ThermostatVoices, AreaVoices, MessageVoices
Programs (1500 × 14 B for OMNI_PRO_II = 21000 B)EventLog (250 × 9 B = 2250 B)
# v >= 2:if Ethernet feature: String8(120) Connection.NetworkAddress String8(5) port-string ("4369" default if parse fails) String8(32) ControllerKey-as-hex <-- 32 hex chars = 16-byte AES-128 keyUInt16 Connection.ModemBaudbool×3 PCModemInitCommand{1,2,3}EnabledString16 AccountRemarks_Extended
# v >= 3:ZoneDescriptions, UnitDescriptions, ButtonDescriptions, CodeDescriptions, ThermostatDescriptions, AreaDescriptions, MessageDescriptions, AudioSourceDescriptions, AudioZoneDescriptions, ProgramRemarks# UserSettings, AccessControl, UPB/Leviton/Phantom/CentraLite/HLC/ZWave/Compose scenes# SetupData2/3/4 (extended setup blocks)Names blocks
Section titled “Names blocks”Each Names block is max_slots * (1 + lenXName) bytes — a flat array of
fixed-width length-prefixed name slots.
For OMNI_PRO_II:
| Block | max_slots | name slot size | total |
|---|---|---|---|
| Zones | 176 | 1+15=16 | 2816 |
| Units | 512 | 1+12=13 | 6656 |
| Buttons | 128 | 1+12=13 | 1664 |
| Codes | 99 | 1+12=13 | 1287 |
| Thermostats | 64 | 1+12=13 | 832 |
| Areas | 8 | 1+12=13 | 104 |
| Messages | 128 | 1+15=16 | 2048 |
| subtotal | 15407 |
Voices blocks
Section titled “Voices blocks”Each “Voice” block lets the panel speak the name of an object. Six phrases
per object. The structured record size depends on the LargeVocabulary
feature: present → 12 bytes per slot (six UInt16s), absent → 6 bytes.
OMNI_PRO_II has LargeVocabulary, so each slot is 12 bytes:
| Block | bytes |
|---|---|
| Zones.Voices | 176 × 12 = 2112 |
| Units.Voices | 511 × 12 + 1 × 6 = 6138 |
| Buttons.Voices | 128 × 12 = 1536 |
| Codes.Voices | 99 × 12 = 1188 |
| Thermostats.Voices | 64 × 12 = 768 |
| Areas.Voices | 8 × 12 = 96 |
| Messages.Voices | 128 × 12 = 1536 |
| subtotal | 13374 |
The Units line has the irregular + 1 × 6 because of a latent bug in PC
Access: Count = 511 but GetFileMaxX = 512,
and the skip path uses a 6-byte buffer instead of 12. One slot reads short
and the parser desyncs unless you replicate the asymmetry.
Connection block (v >= 2 + Ethernet feature)
Section titled “Connection block (v >= 2 + Ethernet feature)”clsHAC.cs:8044-8056:
| Field | Type | Bytes |
|---|---|---|
Connection.NetworkAddress | String8(120) | 1+120 = 121 |
| port-string | String8(5) | 1+5 = 6 |
| ControllerKey-as-hex | String8(32) | 1+32 = 33 |
After read, port-string is parsed as decimal (default 4369 on parse failure)
and the 32-char hex string is right-padded with '0' to 32 chars then fed
through clsUtil.HexString2ByteArray to produce the 16-byte AES-128
ControllerKey.
Verified totals (OMNI_PRO_II)
Section titled “Verified totals (OMNI_PRO_II)”header 2191SetupData 3840flags+counters 10Names (Z+U+Bn+Cd+Tst+Ar+Msg) 15407Voices (with LargeVocab fix) 13374Programs 21000EventLog 2250---------------------------------running total to Connection: 58072 = 0xe2d8For one validation: the panel IP appeared at file offset 0xe2d8 in our
test .pca. Match.
The LargeVocabulary latent bug
Section titled “The LargeVocabulary latent bug”There’s a real but harmless bug in PC Access’s own parser around the Voices blocks. It’s harmless on every shipping panel because the count check happens to satisfy the constraint — except in one corner case. We cover it in detail in the PC Access bug explainer, because it matters if you write your own parser.