Part 9 of P25 End to End, a 14-part deep dive that follows North America’s dominant trunking protocol through GopherTrunk — from a raw C4FM carrier to recorded, named, multi-site voice. Part 8 followed an IMBE voice call from LDU superframes to a WAV on disk. This part is about the calls that should never make that trip: where “encrypted” actually lives on a P25 carrier — one bit on the grant, and a 96-bit Encryption Sync buried inside the voice stream — and the policy machinery that decides what a scarce voice tuner, and your disk, should do about a call no scanner can decode.
TL;DR: P25 signals encryption in two places GopherTrunk reads. The grant’s Service Options octet carries a protected bit (
ServiceOptions.Encrypted) — one bit, known before voice starts. The metadata arrives only in-call: the Encryption Sync — a 72-bit Message Indicator, Algorithm ID and Key ID — rides every LDU2 under a Hamming(10,6,3) inner FEC and an outer RS(24,16,9) (phase1.ParseEncryptionSync). ALGID0x80means clear; anything else names a cipher (0x84= AES-256,0xAA= ADP/RC4). On Phase 2 the same triple rides the MAC_PTT message at key-up (issue #813). Per-systemencrypted_callspolicy — follow / metadata / ignore (issue #711) — decides whether an encrypted call keeps its voice SDR, andrecordings.skip_encrypted(issue #607) keeps undecodable audio off the disk. GopherTrunk identifies encryption; it does not break it.
Key takeaways
- Encryption is signalled twice, at different layers. The grant says that a call is protected, before allocation; only the traffic channel says how. Everything downstream is built around that gap: events that backfill, policies that re-evaluate, recordings that abort.
- The metadata is FEC’d like it matters — and gated like it lies. An
Algorithm ID that survives both FEC layers but lands outside the TIA-102
registry is provably a mis-decode (
p25.AlgorithmKnown, issue #924); a plausible-but-wrong key label is worse than none. - Policy is per system, and configured keys trump policy.
follow/metadata/ignoreare set pertrunking.systems[]entry; a call whose Key ID matches a configuredencryption_keysentry is always followed. - Identify, never decrypt. GopherTrunk surfaces ALGID/KID/MI the way SDRtrunk and OP25 do. Known-key decode exists only for DMR ARC4; on P25 there is no decryption and — everywhere — no key recovery of any kind.
Cheat sheet
| Concern | What it does | Where it lives |
|---|---|---|
| Grant protected bit | “this call is encrypted”, known at grant time | phase1.ServiceOptions.Encrypted (opcodes.go) |
| LDU2 Encryption Sync | MI + ALGID + KID; Hamming(10,6,3) + RS(24,16,9) | internal/radio/p25/phase1/encryption_sync.go (ParseEncryptionSync) |
| Phase 2 key-up sync | the ES triple riding MAC_PTT (slot type, not opcode) | internal/radio/p25/phase2/mac_standard.go (AsPushToTalk) |
| Algorithm registry | ALGID → mnemonic + the out-of-set gate | internal/radio/p25/algorithm.go (AlgorithmName, AlgorithmKnown) |
| In-call event path | composer → bus → engine backfills the live Grant | events.KindCallEncryption (trunking.CallEncryption) |
| Tuner policy | follow / metadata / ignore, per system | internal/trunking/encryptedmode.go (EncryptedMode) |
| Recording guard | never write, or abort-and-delete mid-call | internal/voice/recorder.go (Options.SkipEncrypted) |
In this post
- Two flags, two layers — the one-bit grant flag vs the 96-bit in-call word.
- The Encryption Sync word — MI, ALGID, KID and the FEC under them.
- Phase 2: four layers between a flag and its metadata — the postmortem behind the design.
- From burst to policy — the event path, and what follow/metadata/ignore do.
- What GopherTrunk won’t do — identification vs decryption vs key recovery.
Two flags, two layers
A P25 voice grant
(Part 3)
carries an 8-bit Service Options octet; bit 0x40 is the protected flag:
| Signal | Where | When | What it tells you |
|---|---|---|---|
Service Options bit 0x40 |
grant TSBK / MAC grant, control channel | before the voice SDR is allocated | encrypted yes/no — nothing else |
| Encryption Sync (Phase 1) | every LDU2 on the traffic channel | mid-call, repeating | MI (72 bits) + ALGID + KID |
| MAC_PTT ES (Phase 2) | key-up message on the traffic channel | each talk spurt | the same triple (issue #813) |
The protected bit is the early warning: Grant.Encrypted is set before the
voice pool
ever binds a tuner, which is what lets the ignore policy drop a grant
without spending anything on it. But one bit is all it is — no algorithm,
no key, and on compressed Phase 2 grants not even the bit.
The spec also front-loads the full MI/ALGID/KID triple into the
Header Data Unit
at key-up. GopherTrunk names the HDU in its DUID zoo (DUIDHeader,
Part 2)
and deliberately leaves it undecoded: a trunked tune-in arrives after
key-up — grant, retune, DDC settle, frame lock — so a one-shot header at
call start is exactly the frame a scanner misses. The same triple repeats
in every LDU2, one per 360 ms superframe, and that repeating copy is
the one the pipeline is built on.
The Encryption Sync word
The LDU2 donates the six 40-bit slots an LDU1 spends on Link Control (Part 8) to an Encryption Sync word, under the identical inner FEC — 24 codewords of shortened Hamming(10,6,3) — plus an outer Reed-Solomon layer:
// internal/radio/p25/phase1/encryption_sync.go (shape)
type EncryptionSync struct {
MessageIndicator [9]byte // 72-bit per-call crypto sync vector (the IV)
AlgorithmID uint8 // 0x80 = clear; 0x81 DES-OFB, 0x84 AES-256, …
KeyID uint16 // which key in the radio's keyset
}
func (e EncryptionSync) Encrypted() bool { return e.AlgorithmID != AlgorithmClear }
func ParseEncryptionSync(blocks [LDULCESBlockCount][]byte) (EncryptionSync, int, error) {
data, innerErrs, err := lcInnerDecode(blocks) // 24 × Hamming(10,6,3)
/* … */
info, rsErrs, rerr := framing.DecodeRS24_16(cw[:]) // outer RS(24,16,9), t=4
if rerr != nil {
return EncryptionSync{}, innerErrs, ErrEncryptionSyncUncorrectable
}
/* … octets 0-8 → MI, octet 9 → ALGID, octets 10-11 → KID … */
}
Note what the clear value is not: zero. A clear call advertises ALGID
0x80, so “encrypted” means AlgorithmID != 0x80 — a detail that bites
any decoder treating the field as a boolean. The IDs come from
the TIA-102.AACE-A registry, mirrored in internal/radio/p25/algorithm.go:
0x81 DES-OFB, 0x84 AES-256, 0x85 AES-128, 0xAA ADP/RC4 (the “cheap
encryption” you meet most often), 0x9F DES-XL among others
(full table).
The two FEC layers earn their keep under marginal SNR, but they hide a
trap: an RS decode can report success while “correcting” to garbage when
more errors survive the inner layer than t=4 can honestly fix. A bit-error
smears the Algorithm ID roughly uniformly across 0x00–0xFF — surfaced,
that’s indistinguishable from a real key downstream. So both phases gate on
the registry (p25.AlgorithmKnown, issue #924): an out-of-set ALGID is
dropped with a debug line (composer: p25p1 dropping out-of-set encryption
sync) rather than published. The set tracks AlgorithmName, so a
genuinely new algorithm is admitted the moment it gets a name.
Phase 2: four layers between a flag and its metadata
Phase 2
(Part 7)
tells the same two-layer story with MAC PDUs. The protected bit rides the
grant’s Service Options as on Phase 1 — and also the in-call
GROUP_VOICE_CHANNEL_USER PDU, which matters because Phase 2 grants often
arrive compressed, with no source and no service options; the traffic
channel resolves both mid-call via a KindCallSourceUpdate the engine folds
onto the live call.
The metadata took longer to find. GopherTrunk’s first model put ALGID/KID/MI
in a standalone Encryption Sync MAC opcode (AsEncryptionSync) — and on
real systems the triple doesn’t ride there. It rides the MAC_PTT message
at key-up, identified by its slot type rather than any opcode byte
(AsPushToTalk, issue #813, offsets cross-checked against SDRtrunk).
Getting to that answer was the subject of
From the Issue Tracker Part 3:
a system that flagged every encrypted call correctly but never named an
algorithm or key, because a fictional MAC opcode, a missing carrier loop, a
48-bit sync constant in a 40-bit field and a swapped dibit map were stacked
on top of each other — four layers between a flag and its metadata.
Two design rules survive from that postmortem: the working-model layouts are confined to one file each, so a spec correction is a one-file change; and the protected bit flags encryption independently of the metadata parse, so a wrong layout degrades to “encrypted true, alg/key zero” instead of failing silently.
From burst to policy
The full path a discovered-encrypted call takes through the daemon:
The Phase 1 composer publishes a KindCallEncryption event when an LDU2’s
ES survives both FEC layers and the registry gate — deduplicated, since
ALGID/KID rarely change within a call. The engine backfills the bound
ActiveCall.Grant and republishes with the call’s identity — why a grant
log line grows a suffix mid-call: E(alg=0x84,key=0x01A3) — and both
values persist into the call log, naming which key a recorded call would
need. What the engine then does is the per-system encrypted_calls policy
(issue #711),
Trunking Engine Part 11’s
subject:
follow(the default and the zero value — pre-existing configs see no change): hold the voice SDR for the full call, like a clear one.metadata: follow just long enough to harvest what the traffic channel gives away — talker alias, source RID, the ES itself — then release. The window ismetadata_follow_ms(default 1500 ms); on Phase 2 the release short-circuits the moment the talker alias completes.ignore: never spend a tuner. A flagged grant is dropped before allocation (dropping encrypted grant (encrypted_calls mode: ignore)); encryption discovered mid-call tears the call down with the dedicated end reasonencrypted— a tuner freed by policy, not a decode failure. Emergency grants bypass the policy entirely.
Two exemptions cut across all modes. A call whose Key ID matches a
configured trunking.systems[].encryption_keys entry is always followed
(Engine.keyConfigured disarms any pending release). And the recorder has
its own guard: recordings.skip_encrypted (issue #607) refuses to open a
session for a flagged grant, and when encryption surfaces mid-stream it
closes and deletes the in-progress WAV/raw files and suppresses the
CallComplete event, so upload feeds and webhooks never see the partial —
Recording & Streaming Part 7
walks that abort machinery.
What GopherTrunk won’t do
The honest boundary: GopherTrunk identifies encryption — algorithm,
key ID, message indicator — exactly as SDRtrunk does, and performs no key
recovery of any kind. Known-key decryption exists today only for DMR ARC4
“Enhanced Privacy”, configured under encryption_keys by an operator
authorized to hold the key; on P25 a configured key buys the policy
exemption (the call is followed and recorded), not decryption.
What the identification path does feed is analysis of the signalling
itself: every encrypted LDU2’s MI (the IV) and its still-encrypted IMBE
frames can be handed to the crypto-frame capture sink
(internal/voice/cryptocap) for offline keystream-reuse analysis — the
Crypto Lab
workflow. The bridge captures every encrypted superframe, not one per call
— distinct ciphertexts matter even when ALGID/KID never change — and it is
off by default, the same opt-in discipline as every diagnostic tap in this
series.
How the gap shaped the Go code
- Events backfill instead of blocking. The grant publishes immediately;
KindCallEncryptionandKindCallSourceUpdatepatch the live call as the traffic channel fills the gap — no consumer waits for metadata that may never come. - Gates prefer omission to plausible garbage.
AlgorithmKnownandErrEncryptionSyncUncorrectabledrop low-confidence values — a wrong key label sends an operator chasing the wrong thing. - Policy state lives in one enum with a safe zero.
EncryptedFollowis the zero value and the parse fallback, so a config typo can never silently stop following calls.
Where this goes next
Encryption was the last per-call layer. Part 10 climbs above the call entirely: the WACN / System / RFSS / Site identity ladder, the broadcasts that carry it in TSBK and multi-block AMBT forms, and how GopherTrunk votes a system’s identity out of noisy frames into a neighbour map you can roam by.
FAQ
How does GopherTrunk know a P25 call is encrypted?
Twice over: the grant’s Service Options octet carries a protected bit
(known before the call is even followed), and the traffic channel repeats
the full Encryption Sync — MI, Algorithm ID, Key ID — in every LDU2 on
Phase 1, or in the MAC_PTT key-up message on Phase 2. ALGID 0x80 is
clear; anything else names a cipher.
Can GopherTrunk decrypt encrypted P25 calls? No. It surfaces which algorithm and key a call uses, and performs no key recovery. Known-key decryption exists for DMR ARC4 only; on P25 a configured key exempts the call from the encrypted-call policy but the audio is not decrypted.
Why do some encrypted calls show no algorithm or key? Either the call ended before an LDU2/MAC_PTT landed (the grant flag alone carries no metadata), or the ES word failed its gates — GopherTrunk omits ALGID/KID it cannot trust, because an RS layer “successfully” correcting to an out-of-registry Algorithm ID is a known mis-decode signature (#924).
What does encrypted_calls: metadata actually buy me?
Alias, source and key intelligence without the tuner cost: the engine
follows an encrypted call just long enough to capture the traffic channel’s
metadata, then releases the voice SDR. On a system where a third of the
traffic is encrypted, that’s the difference between clear calls queueing
for tuners and not.
Should I set recordings.skip_encrypted: true?
If you never hold keys, probably: it keeps unplayable audio off the disk
and out of your upload feeds, gating at grant time and mid-call. Leave it
false if a downstream consumer wants the call events regardless — the
suppressed CallComplete also suppresses call webhooks (issue #897) — or if
you deliberately archive ciphertext for cryptolab-style analysis.
Series navigation
Part 9 of 14 · ← Part 8: IMBE Voice — From LDU to WAV · Next → Part 10: Sites, WACNs & Roaming a Multi-Site System