Skip to content

.pca and PCA01.CFG file format

.pca file format and key chain PCA01.CFG is encrypted with the hardcoded keyPC01 and contains a per-installation pca_key field. That pca_key decrypts the .pca account file, whose plaintext starts with the PCA03 magic and contains the panel's network address, port, and ControllerKey for live AES sessions. keyPC01 (hardcoded) 0x14326573 — 32 bits, in source PCA01.CFG · encrypted Borland-Pascal LCG ⊕ stream CFG version tag (e.g. CFG05) modem AT init / dial / hangup commands port + IRQ + baud pca_key (uint32) password, printer port, serial port… decrypts extracted → My_Account.pca · encrypted same XOR cipher, per-install key PCA03 magic (5 bytes) AccountName · AccountAddress · … model byte (e.g. 16 = OMNI_PRO_II) firmware major/minor/revision …body: zones, units, areas, programs, … Connection.NetworkAddress (string) Connection.NetworkPort (string) Connection.ControllerKey (16 bytes) → feeds session-key derivation (quirk #1)

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.

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 Pascal Random() constant. Someone wrote this thing in Delphi originally, ported it to C#, and kept the exact same PRNG so old .pca files still decrypt.
  • % 255, not % 256. The keystream byte is in [0..254], never 0xFF. 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)

Two are hardcoded; the third is per-installation and lives inside PCA01.CFG after first-stage decryption.

// clsPcaCfg
private readonly uint keyPC01 = 338847091u; // 0x142A3D33 for PCA01.CFG
public readonly uint keyExport = 391549495u; // 0x17579817 for exported .pca files

The third path: SetSecurityStamp(string S) derives a per-installation key from a stamp string:

uint num = 305419896u; // 0x12345678 — developer Easter egg as init value
foreach (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.

FileCipher keyWhere 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 Accessper-install Keyinside 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);

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 0

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

After XOR-decrypting with keyPC01:

String8 version_tag ; e.g. "CFG05" — first byte is length=5, then 5 ASCII chars
String8 InitCmd1
[String8 InitCmd2, InitCmd3 if v >= 5]
String8 LocalCmd, OnlineCmd, AnswerCmd, HangupCmd
UInt16 ModemPortNumber, ModemIRQ
[UInt16 ModemBaud if v < 5]
UInt32 Key ; <-- the per-installation .pca encryption key
String8 Password (10 max)
UInt16 PrinterPort
UInt16 SerialPortNumber, SerialBaudCode
ReservedBytes (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.

After XOR-decrypting with the per-install key, the file starts with a fixed 2191-byte header, then a model-dependent body.

clsHAC.ReadFileHeader:

OffsetTypeFieldNotes
0String8version_tagPCA03 (file format v3)
6String8(30)AccountNamefixed-len 30 (1 len + 30 data)
37String16(120)AccountAddressfixed-len 120 (2 len + 120 data)
159String8(20)AccountPhone
180String8(4)AccountCodeshort alarm-account ID
185String16(2000)AccountRemarksup to 2000 bytes
2187byteModelenuModel — 0x10 = OMNI_PRO_II
2188byteMajorVersionfirmware major
2189byteMinorVersionfirmware minor
2190sbyteRevisionfirmware 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 slRequireCodeForSecurity
bool slPasswordOnRestore
UInt16 _discarded
UInt16 EventLog.Count
UInt32 _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 key
UInt16 Connection.ModemBaud
bool×3 PCModemInitCommand{1,2,3}Enabled
String16 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)

Each Names block is max_slots * (1 + lenXName) bytes — a flat array of fixed-width length-prefixed name slots.

For OMNI_PRO_II:

Blockmax_slotsname slot sizetotal
Zones1761+15=162816
Units5121+12=136656
Buttons1281+12=131664
Codes991+12=131287
Thermostats641+12=13832
Areas81+12=13104
Messages1281+15=162048
subtotal15407

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:

Blockbytes
Zones.Voices176 × 12 = 2112
Units.Voices511 × 12 + 1 × 6 = 6138
Buttons.Voices128 × 12 = 1536
Codes.Voices99 × 12 = 1188
Thermostats.Voices64 × 12 = 768
Areas.Voices8 × 12 = 96
Messages.Voices128 × 12 = 1536
subtotal13374

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:

FieldTypeBytes
Connection.NetworkAddressString8(120)1+120 = 121
port-stringString8(5)1+5 = 6
ControllerKey-as-hexString8(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.

header 2191
SetupData 3840
flags+counters 10
Names (Z+U+Bn+Cd+Tst+Ar+Msg) 15407
Voices (with LargeVocab fix) 13374
Programs 21000
EventLog 2250
---------------------------------
running total to Connection: 58072 = 0xe2d8

For one validation: the panel IP appeared at file offset 0xe2d8 in our test .pca. Match.

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.